Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support npm-init and npx commands #89

Merged
merged 14 commits into from
Jun 11, 2023
59 changes: 57 additions & 2 deletions src/content/registry/npm.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { createParseCommand } from './shared';

const npmInstall = /(npm|yarn)( -g)?( global)? (install|i|add|update) /;
const npmInstall = /((npm|yarn)( -g)?( global)? (install|i|add|update)) /;
const npxInstall = /(npx)\b/;
const npmExec = /npm exec/;
const npmCreate = /((npm|yarn) (init|create)) /;
const packageName = /(?<package_name>[a-z0-9_@][a-z0-9_./-]*)/;
const packageVersion = /@(?<package_version>[~^]?\d+(\.(\d|X|x)+){0,2}(-[a-z0-9_-]+)?)/;
const packageLabel = /@[a-z0-9_-]+/;
const fullPackage = new RegExp(String.raw`^${packageName.source}(${packageVersion.source}|${packageLabel.source})?$`);

// npm
const parsePackageString = (str) => {
const match = str.match(fullPackage);
if (!match) return null;
Expand All @@ -16,13 +20,64 @@ const parsePackageString = (str) => {
};
};

export const parseCommand = createParseCommand(
const parseNpmCommand = createParseCommand(
'npm',
(line) => line.match(npmInstall),
(word) => word.length + 1,
parsePackageString
);

// npx
const parseOnlyFirstPackage = (str, argsAndPackagesWords) => {
argsAndPackagesWords.splice(0, argsAndPackagesWords.length);

return parsePackageString(str);
};

const handleNpxArgumentForPackage = (arg, argsAndPackagesWords) => {
const packageArg = '--package=';
if (arg.startsWith(packageArg)) {
argsAndPackagesWords.unshift(arg.slice(packageArg.length));
return packageArg.length;
}
return arg.length + 1;
};

const parseNpxCommand = createParseCommand(
'npm',
(line) => line.match(npxInstall) || line.match(npmExec),
handleNpxArgumentForPackage,
parseOnlyFirstPackage
);

// create-*, @scope/create
const parseCreatePackageString = (str, argsAndPackagesWords) => {
argsAndPackagesWords.splice(0, argsAndPackagesWords.length);

const match = str.match(packageName);
if (!match) return null;

const nameWithCreate = match.groups.package_name.startsWith('@')
? match.groups.package_name + '/create'
: 'create-' + match.groups.package_name;

return {
packageName: nameWithCreate,
packageVersion: match.groups.package_version,
packagePart: str,
};
};

const parseCreateCommand = createParseCommand(
'npm',
(line) => line.match(npmCreate),
(word) => word.length + 1,
parseCreatePackageString
);

export const parseCommands = [parseNpmCommand, parseNpxCommand, parseCreateCommand];

// URL
const urlParser = ({ pathname }) => {
if (!pathname.startsWith('/package/')) return;
const [name, version] = pathname.replace('/package/', '').split('/v/');
Expand Down
64 changes: 55 additions & 9 deletions src/content/registry/npm.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { describe, expect, it } from '@jest/globals';
import { parseCommand } from './npm';
import { parseCommands } from './npm';
import { cli } from './tests-utils';

const parseCommand = (command) => parseCommands.flatMap((parser) => parser(command));

const packageResult = (p) => ({
type: 'npm',
version: undefined,
Expand All @@ -17,11 +19,32 @@ describe('npm', () => {
'npm install',
'npm install -g',
'`npm install node-sass`', // this is not a valid command because of the `
'npm init react-app my-app', // not yet supported. #37
'npm create-react-app my-app',
])('should return empty array if no packages found', (command) => {
expect(parseCommand(command)).toStrictEqual([]);
});

it.each(['npm init <package_name> argument', 'npm create <package_name> argument', 'yarn create <package_name> argument'])(
`'%s' find create-* package`,
(template) => {
const startIndex = template.indexOf('<package_name>');
const endIndex = startIndex + 'react-app'.length;
const command = template.replace('<package_name>', 'react-app');

const packagePosition = parseCommand(command);

expect(packagePosition).toStrictEqual([packageResult({ name: 'create-react-app', startIndex, endIndex })]);
}
);

it.each(['npm init @scope', 'npm create @scope'])(`'%s' find @scope/create package`, (command) => {
const startIndex = command.indexOf('@scope');
const endIndex = startIndex + '@scope'.length;
const packagePosition = parseCommand(command);

expect(packagePosition).toStrictEqual([packageResult({ name: '@scope/create', startIndex, endIndex })]);
});

it('should return the right position for recurrent package name', () => {
const { command, positions } = cli`npm install ${'n'}`;
const expectedPackages = positions.map(({ index, value }) => packageResult({ name: value, startIndex: index }));
Expand All @@ -43,18 +66,21 @@ describe('npm', () => {
expect(packagePosition).toStrictEqual(expectedPackages);
});

it('should range the package with the version part', () => {
const command = 'yarn add -D react@^12.5.0';
it.each(['yarn add -D <package>', 'npx <package> my-app'])('should range the package with the version part', (command) => {
const name = 'create-react-app';
const version = '^12.5.0';
const packagePart = `${name}@${version}`;
const startIndex = command.indexOf('<package>');
const expectedPackages = [
packageResult({
name: 'react',
version: '^12.5.0',
startIndex: 12,
endIndex: 12 + 'react@^12.5.0'.length,
name,
version,
startIndex,
endIndex: startIndex + packagePart.length,
}),
];

const packagePosition = parseCommand(command);
const packagePosition = parseCommand(command.replace('<package>', packagePart));

expect(packagePosition).toStrictEqual(expectedPackages);
});
Expand All @@ -70,5 +96,25 @@ describe('npm', () => {

expect(packagePosition).toStrictEqual(expectedPackages);
});

it('should find create-* in multiple lines', () => {
const { command, positions } = cli`
npx ${'create-react-app'} my-app
npm init ${'react-app'} my-app
yarn create ${'react-app'} my-app
`;

const expectedPackages = positions.map(({ index, value }) =>
packageResult({
name: 'create-react-app',
startIndex: index,
endIndex: index + value.length,
})
);

const packagePosition = parseCommand(command);

expect(packagePosition).toStrictEqual(expectedPackages);
});
});
});
6 changes: 3 additions & 3 deletions src/content/registry/shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ const finishAllWords = (argsAndPackagesWords) => {
/**
* @param {string} registryName `type` in result element
* @param {(line: string) => RegExpMatchArray} getBaseCommandMatch get the base command match for a line (examples: `pip install`, `yarn add`)
* @param {(word: string, argsAndPackagesWords: string[]) => number} handleArgument add a length to the counter to move it to the next argument
* @param {(word: string) => { packageName: string, packageVersion?: string, packagePart: string } | null } parsePackageWord parse a word to its package name and its whole part (includes the version)
* @param {(word: string, argsAndPackagesWords?: string[]) => number} handleArgument add a length to the counter to move it to the next argument
* @param {(word: string, argsAndPackagesWords?: string[]) => { packageName: string, packageVersion?: string, packagePart: string } | null } parsePackageWord parse a word to its package name and its whole part (includes the version)
*/
export const createParseCommand =
(registryName, getBaseCommandMatch, handleArgument, parsePackageWord) =>
Expand Down Expand Up @@ -50,7 +50,7 @@ export const createParseCommand =
continue;
}

const packageMatch = parsePackageWord(word);
const packageMatch = parsePackageWord(word, argsAndPackagesWords);
if (!packageMatch) {
counterIndex += word.length + 1;
continue;
Expand Down
2 changes: 1 addition & 1 deletion src/content/stackoverflow/finder.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const urlParsers = {
...python.urlParsers,
};

const codeBlockParsers = [npm.parseCommand, python.parseCommand, go.parseCommand];
const codeBlockParsers = [...npm.parseCommands, python.parseCommand, go.parseCommand];

export const findRanges = (body) => {
const links = Array.from(body.querySelectorAll(`${POST_SELECTOR} a`))
Expand Down
18 changes: 10 additions & 8 deletions src/content/stackoverflow/finder.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,12 @@ describe(findRanges.name, () => {
'npm i --save-dev <package_name>',
'npm i <package_name> --save-dev',
'npm update <package_name>',
'npm exec <package_name>',
'npm exec --package=<package_name> command',
];

const npxVariants = ['npx <package_name>', 'npx -p <package_name> command', 'npx --package=<package_name> command'];

const yarnVariants = ['yarn add <package_name>', 'yarn add -D <package_name>', 'yarn global add <package_name>'];

const pipVariants = [
Expand All @@ -88,6 +92,7 @@ describe(findRanges.name, () => {
it.each([
...npmVariants.map((cmd) => [cmd, 'npm']),
...yarnVariants.map((cmd) => [cmd, 'npm']),
...npxVariants.map((cmd) => [cmd, 'npm']),
...pipVariants.map((cmd) => [cmd, 'pypi']),
])(`'%s' inside <pre><code>`, (installCommand, type) => {
const commandPackageName = 'my-package-name';
Expand Down Expand Up @@ -330,15 +335,12 @@ describe(findRanges.name, () => {
});

// issue #37, #38
it.skip.each(['npm install git://github.com/user-c/dep-2#node0.8.0', 'npx create-react-app my-app'])(
`Future support '%s`,
(command) => {
const { body } = createCodeBlock(command);
it.each(['npm install git://github.com/user-c/dep-2#node0.8.0'])(`Future support '%s`, (command) => {
const { body } = createCodeBlock(command);

const foundElements = findRanges(body);
const foundElements = findRanges(body);

expect(foundElements.length).toBe(1);
}
);
expect(foundElements.length).toBe(0);
});
});
});
Loading