Skip to content

Commit 8e36e20

Browse files
authored
feat: Support URLs in cspell-gitignore (#7079)
1 parent 03b94c8 commit 8e36e20

12 files changed

+137
-87
lines changed

cspell.code-workspace

-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@
5050
"run",
5151
"--testTimeout=600000",
5252
"--hideSkippedTests",
53-
"--reporter=basic",
5453
"--no-file-parallelism",
5554
"${relativeFile}"
5655
],

packages/cspell-gitignore/package.json

+1-2
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@
5959
"dependencies": {
6060
"@cspell/url": "workspace:*",
6161
"cspell-glob": "workspace:*",
62-
"cspell-io": "workspace:*",
63-
"find-up-simple": "^1.0.1"
62+
"cspell-io": "workspace:*"
6463
}
6564
}

packages/cspell-gitignore/src/GitIgnore.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ describe('GitIgnoreServer', () => {
3636
${__dirname} | ${undefined} | ${[gitRoot, pkg]}
3737
${__dirname} | ${[packages]} | ${[pkg]}
3838
${__dirname} | ${[pkg, gitRoot]} | ${[pkg]}
39+
${dirUrl.href} | ${[pkgUrl.href]} | ${[pkg]}
3940
${p(samples, 'ignored')} | ${undefined} | ${[gitRoot, pkg, samples]}
4041
${p(pkgCSpellLib)} | ${[pkg]} | ${[gitRoot, pkgCSpellLib]}
4142
${p(pkgCSpellLib)} | ${[packages]} | ${[pkgCSpellLib]}

packages/cspell-gitignore/src/GitIgnore.ts

+49-28
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import * as path from 'node:path';
1+
import { toFileDirURL, toFileURL, urlDirname } from '@cspell/url';
2+
import type { VFileSystem } from 'cspell-io';
23

34
import type { IsIgnoredExResult } from './GitIgnoreFile.js';
45
import { GitIgnoreHierarchy, loadGitIgnore } from './GitIgnoreFile.js';
5-
import { contains } from './helpers.js';
6+
import { isParentOf } from './utils.js';
67

78
/**
89
* Class to cache and process `.gitignore` file queries.
@@ -12,37 +13,43 @@ export class GitIgnore {
1213
private knownGitIgnoreHierarchies = new Map<string, Promise<GitIgnoreHierarchy>>();
1314
private _roots: Set<string>;
1415
private _sortedRoots: string[];
16+
private _vfs: VFileSystem | undefined;
1517

1618
/**
1719
* @param roots - (search roots) an optional array of root paths to prevent searching for `.gitignore` files above the root.
1820
* If a file is under multiple roots, the closest root will apply. If a file is not under any root, then
1921
* the search for `.gitignore` will go all the way to the system root of the file.
2022
*/
21-
constructor(roots: string[] = []) {
23+
constructor(roots: (string | URL)[] = [], vfs?: VFileSystem) {
24+
this._vfs = vfs;
2225
this._sortedRoots = resolveAndSortRoots(roots);
2326
this._roots = new Set(this._sortedRoots);
2427
}
2528

26-
findResolvedGitIgnoreHierarchy(directory: string): GitIgnoreHierarchy | undefined {
27-
return this.resolvedGitIgnoreHierarchies.get(directory);
29+
findResolvedGitIgnoreHierarchy(directory: string | URL): GitIgnoreHierarchy | undefined {
30+
return this.resolvedGitIgnoreHierarchies.get(toFileDirURL(directory).href);
2831
}
2932

30-
isIgnoredQuick(file: string): boolean | undefined {
31-
const gh = this.findResolvedGitIgnoreHierarchy(path.dirname(file));
32-
return gh?.isIgnored(file);
33+
isIgnoredQuick(file: string | URL): boolean | undefined {
34+
const uFile = toFileURL(file);
35+
const gh = this.findResolvedGitIgnoreHierarchy(getDir(uFile));
36+
return gh?.isIgnored(uFile);
3337
}
3438

35-
async isIgnored(file: string): Promise<boolean> {
36-
const gh = await this.findGitIgnoreHierarchy(path.dirname(file));
37-
return gh.isIgnored(file);
39+
async isIgnored(file: string | URL): Promise<boolean> {
40+
const uFile = toFileURL(file);
41+
const gh = await this.findGitIgnoreHierarchy(getDir(uFile));
42+
return gh.isIgnored(uFile);
3843
}
3944

40-
async isIgnoredEx(file: string): Promise<IsIgnoredExResult | undefined> {
41-
const gh = await this.findGitIgnoreHierarchy(path.dirname(file));
42-
return gh.isIgnoredEx(file);
45+
async isIgnoredEx(file: string | URL): Promise<IsIgnoredExResult | undefined> {
46+
const uFile = toFileURL(file);
47+
const gh = await this.findGitIgnoreHierarchy(getDir(uFile));
48+
return gh.isIgnoredEx(uFile);
4349
}
4450

45-
async findGitIgnoreHierarchy(directory: string): Promise<GitIgnoreHierarchy> {
51+
async findGitIgnoreHierarchy(directory: string | URL): Promise<GitIgnoreHierarchy> {
52+
directory = toFileDirURL(directory).href;
4653
const known = this.knownGitIgnoreHierarchies.get(directory);
4754
if (known) {
4855
return known;
@@ -77,16 +84,17 @@ export class GitIgnore {
7784
return this._sortedRoots;
7885
}
7986

80-
addRoots(roots: string[]): void {
81-
const rootsToAdd = roots.map((p) => path.resolve(p)).filter((r) => !this._roots.has(r));
87+
addRoots(roots: (string | URL)[]): void {
88+
const rootsToAdd = roots.map((r) => toFileDirURL(r).href).filter((r) => !this._roots.has(r));
8289
if (!rootsToAdd.length) return;
8390

8491
rootsToAdd.forEach((r) => this._roots.add(r));
8592
this._sortedRoots = resolveAndSortRoots([...this._roots]);
8693
this.cleanCachedEntries();
8794
}
8895

89-
peekGitIgnoreHierarchy(directory: string): Promise<GitIgnoreHierarchy> | undefined {
96+
peekGitIgnoreHierarchy(directory: string | URL): Promise<GitIgnoreHierarchy> | undefined {
97+
directory = toFileDirURL(directory).href;
9098
return this.knownGitIgnoreHierarchies.get(directory);
9199
}
92100

@@ -100,42 +108,55 @@ export class GitIgnore {
100108
this.resolvedGitIgnoreHierarchies.clear();
101109
}
102110

103-
private async _findGitIgnoreHierarchy(directory: string): Promise<GitIgnoreHierarchy> {
111+
private async _findGitIgnoreHierarchy(directory: string | URL): Promise<GitIgnoreHierarchy> {
112+
directory = toFileDirURL(directory);
104113
const root = this.determineRoot(directory);
105-
const parent = path.dirname(directory);
114+
const parent = urlDirname(directory);
106115
const parentHierarchy =
107-
parent !== directory && contains(root, parent) ? await this.findGitIgnoreHierarchy(parent) : undefined;
108-
const git = await loadGitIgnore(directory);
116+
parent.href !== directory.href && isParentOf(root, parent)
117+
? await this.findGitIgnoreHierarchy(parent)
118+
: undefined;
119+
const git = await loadGitIgnore(directory, this._vfs);
109120
if (!git) {
110121
return parentHierarchy || new GitIgnoreHierarchy([]);
111122
}
112123
const chain = parentHierarchy ? [...parentHierarchy.gitIgnoreChain, git] : [git];
113124
return new GitIgnoreHierarchy(chain);
114125
}
115126

116-
private determineRoot(directory: string): string {
127+
private determineRoot(directory: string | URL): string {
128+
const uDir = toFileDirURL(directory);
117129
const roots = this.roots;
118130
for (let i = roots.length - 1; i >= 0; --i) {
119131
const r = roots[i];
120-
if (contains(r, directory)) return r;
132+
if (uDir.href.startsWith(r)) return r;
121133
}
122-
return path.parse(directory).root;
134+
return new URL('/', uDir).href;
123135
}
124136
}
125137

126-
function resolveAndSortRoots(roots: string[]): string[] {
127-
const sortedRoots = roots.map((a) => path.resolve(a));
138+
/**
139+
* Convert the roots into urls strings.
140+
* @param roots
141+
* @returns
142+
*/
143+
function resolveAndSortRoots(roots: (string | URL)[]): string[] {
144+
const sortedRoots = roots.map((a) => toFileDirURL(a).href);
128145
sortRoots(sortedRoots);
129146
Object.freeze(sortedRoots);
130147
return sortedRoots;
131148
}
132149

150+
function getDir(file: string | URL): URL {
151+
return urlDirname(toFileURL(file));
152+
}
153+
133154
/**
134155
* Sorts root paths based upon their length.
135156
* @param roots - array to be sorted
136157
*/
137158
function sortRoots(roots: string[]): string[] {
138-
roots.sort((a, b) => a.length - b.length);
159+
roots.sort((a, b) => a.length - b.length || a.localeCompare(b));
139160
return roots;
140161
}
141162

packages/cspell-gitignore/src/GitIgnoreFile.ts

+34-27
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { promises as fs } from 'node:fs';
2-
import * as path from 'node:path';
3-
1+
import { toFileDirURL, toFilePathOrHref, toFileURL, urlDirname } from '@cspell/url';
42
import type { GlobMatchRule, GlobPatternNormalized, GlobPatternWithRoot } from 'cspell-glob';
53
import { GlobMatcher } from 'cspell-glob';
4+
import { getDefaultVirtualFs, VFileSystem } from 'cspell-io';
65

7-
import { isDefined, isParentOf, makeRelativeTo } from './helpers.js';
6+
import { isDefined, isParentOf, makeRelativeTo } from './utils.js';
87

98
export interface IsIgnoredExResult {
109
glob: string | undefined;
@@ -20,56 +19,58 @@ export interface IsIgnoredExResult {
2019
export class GitIgnoreFile {
2120
constructor(
2221
readonly matcher: GlobMatcher,
23-
readonly gitignore: string,
22+
readonly gitignore: string | URL,
2423
) {}
2524

2625
get root(): string {
2726
return this.matcher.root;
2827
}
2928

30-
isIgnored(file: string): boolean {
31-
return this.matcher.match(file);
29+
isIgnored(file: string | URL): boolean {
30+
return this.matcher.match(file.toString());
3231
}
3332

34-
isIgnoredEx(file: string): IsIgnoredExResult {
35-
const m = this.matcher.matchEx(file);
33+
isIgnoredEx(file: string | URL): IsIgnoredExResult {
34+
const m = this.matcher.matchEx(file.toString());
3635
const { matched } = m;
3736
const partial: Partial<GlobMatchRule> = m;
3837
const pattern: Partial<GlobPatternNormalized> | undefined = partial.pattern;
3938
const glob = pattern?.rawGlob ?? partial.glob;
4039
const root = partial.root;
4140
const line = pattern?.line;
42-
return { glob, matched, gitIgnoreFile: this.gitignore, root, line };
41+
return { glob, matched, gitIgnoreFile: toFilePathOrHref(this.gitignore), root, line };
4342
}
4443

4544
getGlobPatters(): GlobPatternWithRoot[] {
4645
return this.matcher.patterns;
4746
}
4847

49-
getGlobs(relativeTo: string): string[] {
48+
getGlobs(relativeToDir: string | URL): string[] {
5049
return this.getGlobPatters()
51-
.map((pat) => globToString(pat, relativeTo))
50+
.map((pat) => globToString(pat, relativeToDir))
5251
.filter(isDefined);
5352
}
5453

55-
static parseGitignore(content: string, gitignoreFilename: string): GitIgnoreFile {
56-
const options = { root: path.dirname(gitignoreFilename) };
54+
static parseGitignore(content: string, gitignoreFilename: string | URL): GitIgnoreFile {
55+
gitignoreFilename = toFileURL(gitignoreFilename);
56+
const root = urlDirname(gitignoreFilename).href;
57+
const options = { root };
5758
const globs = content
5859
.split(/\r?\n/g)
5960
.map((glob, index) => ({
6061
glob: glob.replace(/^#.*/, ''),
61-
source: gitignoreFilename,
62+
source: gitignoreFilename.toString(),
6263
line: index + 1,
6364
}))
6465
.filter((g) => !!g.glob);
6566
const globMatcher = new GlobMatcher(globs, options);
6667
return new GitIgnoreFile(globMatcher, gitignoreFilename);
6768
}
6869

69-
static async loadGitignore(gitignore: string): Promise<GitIgnoreFile> {
70-
gitignore = path.resolve(gitignore);
71-
const content = await fs.readFile(gitignore, 'utf8');
72-
return this.parseGitignore(content, gitignore);
70+
static async loadGitignore(gitignore: string | URL, vfs: VFileSystem): Promise<GitIgnoreFile> {
71+
gitignore = toFileURL(gitignore);
72+
const file = await vfs.readFile(gitignore, 'utf8');
73+
return this.parseGitignore(file.getText(), gitignore);
7374
}
7475
}
7576

@@ -81,7 +82,7 @@ export class GitIgnoreHierarchy {
8182
mustBeHierarchical(gitIgnoreChain);
8283
}
8384

84-
isIgnored(file: string): boolean {
85+
isIgnored(file: string | URL): boolean {
8586
for (const git of this.gitIgnoreChain) {
8687
if (git.isIgnored(file)) return true;
8788
}
@@ -94,7 +95,7 @@ export class GitIgnoreHierarchy {
9495
* @param file - fsPath to check.
9596
* @returns IsIgnoredExResult of the match or undefined if there was no match.
9697
*/
97-
isIgnoredEx(file: string): IsIgnoredExResult | undefined {
98+
isIgnoredEx(file: string | URL): IsIgnoredExResult | undefined {
9899
for (const git of this.gitIgnoreChain) {
99100
const r = git.isIgnoredEx(file);
100101
if (r.matched) return r;
@@ -112,10 +113,12 @@ export class GitIgnoreHierarchy {
112113
}
113114
}
114115

115-
export async function loadGitIgnore(dir: string): Promise<GitIgnoreFile | undefined> {
116-
const file = path.join(dir, '.gitignore');
116+
export async function loadGitIgnore(dir: string | URL, vfs?: VFileSystem): Promise<GitIgnoreFile | undefined> {
117+
dir = toFileDirURL(dir);
118+
vfs ??= getDefaultVirtualFs().getFS(dir);
119+
const file = new URL('.gitignore', dir);
117120
try {
118-
return await GitIgnoreFile.loadGitignore(file);
121+
return await GitIgnoreFile.loadGitignore(file, vfs);
119122
} catch {
120123
return undefined;
121124
}
@@ -131,12 +134,16 @@ function mustBeHierarchical(chain: GitIgnoreFile[]): void {
131134
}
132135
}
133136

134-
function globToString(glob: GlobPatternWithRoot, relativeTo: string): string | undefined {
137+
function globToString(glob: GlobPatternWithRoot, relativeToDir: string | URL): string | undefined {
135138
if (glob.isGlobalPattern) return glob.glob;
136139

137-
if (isParentOf(glob.root, relativeTo) && glob.glob.startsWith('**/')) return glob.glob;
140+
relativeToDir = toFileDirURL(relativeToDir);
141+
142+
const root = toFileDirURL(glob.root);
143+
144+
if (isParentOf(root, relativeToDir) && glob.glob.startsWith('**/')) return glob.glob;
138145

139-
const base = makeRelativeTo(glob.root, relativeTo);
146+
const base = makeRelativeTo(root, relativeToDir);
140147
if (base === undefined) return undefined;
141148
return (base ? base + '/' : '') + glob.glob;
142149
}

packages/cspell-gitignore/src/app.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as path from 'node:path';
22

3+
import { findRepoRoot } from './findRepoRoot.js';
34
import { GitIgnore } from './GitIgnore.js';
4-
import { findRepoRoot } from './helpers.js';
55

66
type OptionParser = (params: string[]) => string[];
77

@@ -42,7 +42,7 @@ export async function run(args: string[]): Promise<void> {
4242
const pFile = gi.isIgnoredEx(file);
4343
const pDir = gi.isIgnoredEx(file + '/');
4444
const r = (await pFile) || (await pDir);
45-
console.warn('%o', { pFile: await pFile, pDir: await pDir });
45+
// console.warn('%o', { pFile: await pFile, pDir: await pDir });
4646
const gitignore = r?.gitIgnoreFile ? path.relative(repo, r.gitIgnoreFile) : '';
4747
const line = r?.line || '';
4848
const glob = r?.glob || '';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { toFileDirURL, toFilePathOrHref } from '@cspell/url';
2+
import { getDefaultVirtualFs, type VFileSystem } from 'cspell-io';
3+
4+
/**
5+
* Find the git repository root directory.
6+
* @param directory - directory to search up from.
7+
* @returns resolves to `.git` root or undefined
8+
*/
9+
export async function findRepoRoot(directory: string | URL, vfs?: VFileSystem): Promise<string | undefined> {
10+
directory = toFileDirURL(directory);
11+
vfs = vfs || getDefaultVirtualFs().getFS(directory);
12+
const foundDir = await vfs.findUp('.git', directory, { type: 'directory' });
13+
const foundFile = await vfs.findUp('.git', directory, { type: 'file' });
14+
const found = foundDir || foundFile;
15+
if (!found) return undefined;
16+
return toFilePathOrHref(new URL('.', found));
17+
}

packages/cspell-gitignore/src/helpers.test.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { win32 } from 'node:path';
33

44
import { describe, expect, test } from 'vitest';
55

6-
import { contains, directoryRoot, factoryPathHelper, findRepoRoot, isParentOf, makeRelativeTo } from './helpers.js';
6+
import { findRepoRoot } from './findRepoRoot.js';
7+
import { contains, directoryRoot, factoryPathHelper, isParentOf, makeRelativeTo } from './helpers.js';
78

89
const pkg = path.resolve(__dirname, '..');
910
const gitRoot = path.resolve(pkg, '../..');
@@ -24,7 +25,7 @@ describe('helpers', () => {
2425
${'/'} | ${undefined}
2526
`('findRepoRoot $dir', async ({ dir, expected }) => {
2627
const f = await findRepoRoot(dir);
27-
expect(f).toEqual(expected);
28+
expect(f ? path.join(f, '.') : f).toEqual(expected);
2829
});
2930

3031
test.each`

0 commit comments

Comments
 (0)