Skip to content

Commit

Permalink
feat: support npm-init and npx commands (#89)
Browse files Browse the repository at this point in the history
Support `npx`, `npm exec`, `npm create`, `npm init`, `yarn create`.

Close #37

---------

Co-authored-by: Baruch Odem <baruch.odem@checkmarx.com>
Co-authored-by: Baruch Odem (Rothkoff) <baruchiro@gmail.com>
  • Loading branch information
3 people authored Jun 11, 2023
1 parent c726fe5 commit 288292e
Show file tree
Hide file tree
Showing 7 changed files with 550 additions and 23 deletions.
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

0 comments on commit 288292e

Please sign in to comment.