Skip to content

Commit

Permalink
Support filename expansion of * and ?
Browse files Browse the repository at this point in the history
  • Loading branch information
ianthomas23 committed Nov 1, 2024
1 parent 31c2a61 commit 2b99718
Show file tree
Hide file tree
Showing 5 changed files with 174 additions and 3 deletions.
68 changes: 67 additions & 1 deletion src/shell_impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,71 @@ export class ShellImpl implements IShellWorker {
this.options.terminateCallback();
}

_filenameExpansion(args: string[]): string[] {
let ret: string[] = []
let nFlags = 0;

// ToDo:
// - Handling of absolute paths
// - Handling of . and .. and hidden files
// - Wildcards in quoted strings should be ignored
// - [ab] syntax
// - Multiple wildcards in different directory levels in the same arg
for (const arg of args) {
if (arg.startsWith('-')) {
nFlags++;
ret.push(arg);
continue;
} else if (!(arg.includes('*') || arg.includes('?'))) {
ret.push(arg);
continue;
}

const { FS } = this._fileSystem!;
const analyze = FS.analyzePath(arg, false);
if (!analyze.parentExists) {
ret.push(arg);
continue;
}
const parentPath = analyze.parentPath;

// Assume relative path.
let relativePath = parentPath;
const pwd = FS.cwd();
if (relativePath.startsWith(pwd)) {
relativePath = relativePath.slice(pwd.length);
if (relativePath.startsWith('/')) {
relativePath = relativePath.slice(1);
}
}

let possibles = FS.readdir(parentPath);

// Transform match string to a regex.
// Escape special characters, * and ? dealt with separately.
let match = analyze.name.replace(/[.+^${}()|[\]\\]/g, '\\$&');
match = match.replaceAll('*', '.*');
match = match.replaceAll('?', '.');
const regex = new RegExp(`^${match}$`);
possibles = possibles.filter((path: string) => path.match(regex));

// Remove all . files/directories; need to fix this.
possibles = possibles.filter((path: string) => !path.startsWith('.'));

if (relativePath.length > 0) {
possibles = possibles.map((path: string) => relativePath + '/' + path);
}
ret = ret.concat(possibles);
}

if (ret.length == nFlags) {
// If no matches return initial arguments.
ret = args;
}

return ret;
}

private async _initFilesystem(): Promise<void> {
const { wasmBaseUrl } = this.options;
const fsModule = this._wasmLoader.getModule('fs');
Expand Down Expand Up @@ -369,7 +434,8 @@ export class ShellImpl implements IShellWorker {
}
}

const args = commandNode.suffix.map(token => token.value);
let args = commandNode.suffix.map(token => token.value);
args = this._filenameExpansion(args);
const context = new Context(
args,
this._fileSystem!,
Expand Down
3 changes: 2 additions & 1 deletion test/serve/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Aliases, parse, tokenize } from '@jupyterlite/cockle';
import { terminalInput } from './input_setup';
import { shell_setup_empty, shell_setup_simple } from './shell_setup';
import { shell_setup_empty, shell_setup_complex, shell_setup_simple } from './shell_setup';

async function setup() {
const cockle = {
Aliases,
parse,
shell_setup_complex,
shell_setup_empty,
shell_setup_simple,
terminalInput,
Expand Down
26 changes: 25 additions & 1 deletion test/serve/shell_setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,39 @@ export async function shell_setup_simple(options: IOptions = {}): Promise<IShell
return await _shell_setup_common(options, 1);
}

export async function shell_setup_complex(options: IOptions = {}): Promise<IShellSetup> {
return await _shell_setup_common(options, 2);
}

async function _shell_setup_common(options: IOptions, level: number): Promise<IShellSetup> {
const output = new MockTerminalOutput(false);

const initialDirectories = options.initialDirectories ?? [];
const initialFiles = options.initialFiles ?? {};
if (level > 0) {
if (level === 1) {
// 📁 dirA
// 📄 file1
// 📄 file2
initialDirectories.push('dirA');
initialFiles['file1'] = 'Contents of the file';
initialFiles['file2'] = 'Some other file\nSecond line';
} else if (level === 2) {
// 📄 file1.txt
// 📄 file2.txt
// 📄 otherfile
// 📁 dir
// ├── 📄 subfile.txt
// ├── 📄 subfile.md
// └── 📁 subdir
// └── 📄 nestedfile
initialDirectories.push('dir');
initialDirectories.push('dir/subdir');
initialFiles['file1.txt'] = '';
initialFiles['file2.txt'] = '';
initialFiles['otherfile'] = '';
initialFiles['dir/subfile.txt'] = '';
initialFiles['dir/subfile.md'] = '';
initialFiles['dir/subdir/nestedfile'] = '';
}

const shell = new Shell({
Expand Down
52 changes: 52 additions & 0 deletions test/tests/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { expect } from '@playwright/test';
import {
shellInputsSimple,
shellInputsSimpleN,
shellLineComplex,
shellLineComplexN,
shellLineSimple,
shellLineSimpleN,
test
Expand Down Expand Up @@ -421,4 +423,54 @@ test.describe('Shell', () => {
expect(output['signalled']).toBeTruthy();
});
});

test.describe('filename expansion', () => {
test('should expand * in pwd', async ({ page }) => {
const output0 = await shellLineComplex(page, 'ls file*');
expect(output0).toMatch('\r\nfile1.txt file2.txt\r\n');

const output1 = await shellLineComplex(page, 'ls *file');
expect(output1).toMatch('\r\notherfile\r\n');

const output2 = await shellLineComplex(page, 'ls *file*');
expect(output2).toMatch('\r\nfile1.txt file2.txt otherfile\r\n');
});

test('should include directory contents in match', async ({ page }) => {
const output = await shellLineComplex(page, 'ls *');
expect(output).toMatch('\r\nfile1.txt file2.txt otherfile\r\n\r\ndir:\r\nsubdir subfile.md subfile.txt\r\n');
});

test('should expand ? in pwd', async ({ page }) => {
const output0 = await shellLineComplex(page, 'ls file?.txt');
expect(output0).toMatch('\r\nfile1.txt file2.txt\r\n');

const output1 = await shellLineComplex(page, 'ls file2?txt');
expect(output1).toMatch('\r\nfile2.txt\r\n');
});

test('should use original pattern if no matches', async ({ page }) => {
const output0 = await shellLineComplex(page, 'ls z*');
expect(output0).toMatch("ls: cannot access 'z*': No such file or directory");

const output1 = await shellLineComplex(page, 'ls z?');
expect(output1).toMatch("ls: cannot access 'z?': No such file or directory");
});

test('should match special characters', async ({ page }) => {
const output = await shellLineComplexN(page, [
'touch ab+c',
'ls a*',
'ls *+c',
]);
expect(output[1]).toMatch('\r\nab+c\r\n');
expect(output[2]).toMatch('\r\nab+c\r\n');
});

test('should expand * in subdirectory', async ({ page }) => {
const output0 = await shellLineComplex(page, 'ls dir/subf*');
expect(output0).toMatch(/\r\ndir\/subfile\.md\s+dir\/subfile\.txt\r\n/);

});
});
});
28 changes: 28 additions & 0 deletions test/tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,38 @@ export async function shellLineSimpleN(
);
}

export async function shellLineComplexN(
page: Page,
lines: string[],
options: IOptions = {}
): Promise<string[]> {
return await page.evaluate(
async ({ lines, options }) => {
const { shell, output } = await globalThis.cockle.shell_setup_complex(options);
const ret: string[] = [];
for (const line of lines) {
await shell.inputLine(line);
ret.push(output.text);
output.clear();
}
return ret;
},
{ lines, options }
);
}

export async function shellLineSimple(
page: Page,
line: string,
options: IOptions = {}
): Promise<string> {
return (await shellLineSimpleN(page, [line], options))[0];
}

export async function shellLineComplex(
page: Page,
line: string,
options: IOptions = {}
): Promise<string> {
return (await shellLineComplexN(page, [line], options))[0];
}

0 comments on commit 2b99718

Please sign in to comment.