Skip to content

Commit

Permalink
[L] Add new output options that make use of CST node location data (#84)
Browse files Browse the repository at this point in the history
  • Loading branch information
maxh committed Nov 24, 2023
1 parent 47a65e3 commit 746cc89
Show file tree
Hide file tree
Showing 30 changed files with 551 additions and 81 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Add new `-o, --output` option which accepts `simple` (the default), `none`, `contextual`, `filepath`, and `json`.

## 0.0.23 (2023-11-15)

- Allow ignoring required fields with default values in `forbid-required-ignored-field`.
Expand Down
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,52 @@ model User {

Omitting `revisionNumber` and `revisionCreatedAt` fields from this model will not result in a violation. Other required fields remain required.

## Output

There are a few output options.

### Simple (default)

```
example/invalid.prisma ✖
Users 11:1
error Expected singular model name. model-name-grammatical-number
error Missing required fields: "createdAt". require-field
Users.emailAddress 13:3
error Field name must be mapped to snake case. field-name-mapping-snake-case
example/valid.prisma ✔
```

### Contextual

```
example/invalid.prisma:11:1 Users
model Users {
^^^^^^^^^^^
error Expected singular model name. model-name-grammatical-number
error Missing required fields: "createdAt". require-field
example/invalid.prisma:13:3 Users.emailAddress
emailAddress String
^^^^^^^^^^^^
error Field name must be mapped to snake case. field-name-mapping-snake-case
```

### Filepath

```
example/invalid.prisma ✖
example/valid.prisma ✔
```

### None

No output, for when you just want to use the status code.

### JSON

Returns a serialized JSON object with list of violations. Useful for editor plugins.

## Contributing

Pull requests are welcome. Please see [DEVELOPMENT.md](./DEVELOPMENT.md).
5 changes: 5 additions & 0 deletions example/invalid-simple.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
model DbUser {
id String @id
fooBar String
@@map(name: "users")
}
2 changes: 1 addition & 1 deletion example/invalid.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@ model UserRoleFoo {

model UserRole {
id String @id
userId String
userId String @map(name: "userid")
// No mapping.
}
3 changes: 3 additions & 0 deletions jest-setup/unit-test-setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import chalk from 'chalk';

chalk.level = 2;
6 changes: 5 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const unitTestConfig = {
moduleDirectories: ['node_modules'],
extensionsToTreatAsEsm: ['.ts'],
moduleFileExtensions: ['js', 'ts'],
setupFiles: ['./jest-setup/unit-test-setup.js'],
roots: ['<rootDir>/src/'],
testEnvironment: 'node',
testRegex: '.*\\.test\\.ts$',
Expand All @@ -23,7 +24,10 @@ const unitTestConfig = {
'^#src/(.*)\\.js$': '<rootDir>/src/$1',
},
transform: {
'^.+\\.ts$': ['ts-jest', { tsconfig: './tsconfig.test.json' }],
'^.+\\.ts$': [
'ts-jest',
{ useESM: true, tsconfig: './tsconfig.test.json' },
],
},
};

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@
"style:eslint:check": "eslint src --max-warnings=0 --cache",
"style:prettier": "prettier --write src",
"style:prettier:check": "prettier --check src",
"test": "node ./node_modules/jest/bin/jest.js",
"test": "NODE_OPTIONS=--experimental-vm-modules node ./node_modules/jest/bin/jest.js",
"test:cli:invalid": "dist/cli.js fixture/invalid.prisma"
},
"dependencies": {
"@kejistan/enum": "^0.0.2",
"@mrleebo/prisma-ast": "^0.7.0",
"@mrleebo/prisma-ast": "^0.8.0",
"chalk": "^5.2.0",
"commander": "^11.0.0",
"cosmiconfig": "^8.1.3",
Expand Down
59 changes: 27 additions & 32 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import { glob } from 'glob';

import { readPackageUp } from 'read-package-up';

import { getTruncatedFileName } from '#src/common/file.js';
import { parseRules } from '#src/common/parse-rules.js';
import { renderViolations } from '#src/common/render.js';
import { lintPrismaFiles } from '#src/lint-prisma-files.js';
import { outputToConsole } from '#src/output/console.js';
import ruleDefinitions from '#src/rule-definitions.js';

const DEFAULT_PRISMA_FILE_PATH = 'prisma/schema.prisma';
Expand All @@ -25,6 +26,11 @@ program
'A path to a config file. ' +
'If omitted, cosmiconfig is used to search for a config file.',
)
.option(
'-o, --output-format <format>',
'Output format. Options: simple, contextual, json, filepath, none.',
'simple',
)
.option('--no-color', 'Disable color output.')
.option('--quiet', 'Suppress all output except for errors.')
.argument(
Expand Down Expand Up @@ -92,64 +98,53 @@ const resolvePrismaFiles = async (args: string[]) => {
return resolvedFiles;
};

function getTruncatedFileName(fileName: string) {
const cwd = process.cwd();
return fileName.includes(cwd)
? path.relative(process.cwd(), fileName)
: fileName;
}

/* eslint-disable no-console */
const outputParseIssues = (filepath: string, parseIssues: string[]) => {
const truncatedFileName = getTruncatedFileName(filepath);
// eslint-disable-next-line no-console
console.error(`${truncatedFileName} ${chalk.red('✖')}`);
for (const parseIssue of parseIssues) {
// eslint-disable-next-line no-console
console.error(` ${parseIssue.replaceAll('\n', '\n ')}`);
}
process.exit(1);
};

const run = async () => {
if (!options.color) {
chalk.level = 0;
}
const { quiet } = options;
const { quiet, outputFormat } = options;
const rootConfig = await getRootConfigResult();
if (rootConfig == null) {
// eslint-disable-next-line no-console
console.error(
'Unable to find configuration file for prisma-lint. Please create a ".prismalintrc.json" file.',
);
process.exit(1);
}
const { rules, parseIssues } = parseRules(ruleDefinitions, rootConfig.config);
if (parseIssues.length > 0) {
const truncatedFileName = getTruncatedFileName(rootConfig.filepath);
console.error(`${truncatedFileName} ${chalk.red('✖')}`);
for (const parseIssue of parseIssues) {
console.error(` ${parseIssue.replaceAll('\n', '\n ')}`);
}
process.exit(1);
outputParseIssues(rootConfig.filepath, parseIssues);
}

const fileNames = await resolvePrismaFiles(args);
const fileViolationList = await lintPrismaFiles({
rules,
fileNames,
});
let hasViolations = false;
fileViolationList.forEach(({ fileName, violations }) => {
const truncatedFileName = getTruncatedFileName(fileName);
if (violations.length > 0) {
hasViolations = true;
console.error(`${truncatedFileName} ${chalk.red('✖')}`);
const lines = renderViolations(violations);
for (const line of lines) {
console.error(line);
}
} else {
if (!quiet) {
console.log(`${truncatedFileName} ${chalk.green('✔')}`);
}
}
});

outputToConsole(fileViolationList, outputFormat, quiet);

const hasViolations = fileViolationList.some(
({ violations }) => violations.length > 0,
);
if (hasViolations) {
process.exit(1);
}
};

run().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
// Something's wrong with prisma-lint.
process.exit(2);
Expand Down
14 changes: 14 additions & 0 deletions src/common/file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { getTruncatedFileName } from '#src/common/file.js';

describe('get truncated file name', () => {
it('returns truncated file name', () => {
const result = getTruncatedFileName('src/common/file.spec.ts');
expect(result).toBe('src/common/file.spec.ts');
});

it('strips the current cwd', () => {
const cwd = process.cwd();
const result = getTruncatedFileName(`${cwd}/src/common/file.spec.ts`);
expect(result).toBe('src/common/file.spec.ts');
});
});
8 changes: 8 additions & 0 deletions src/common/file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import path from 'path';

export function getTruncatedFileName(fileName: string) {
const cwd = process.cwd();
return fileName.includes(cwd)
? path.relative(process.cwd(), fileName)
: fileName;
}
13 changes: 13 additions & 0 deletions src/common/get-prisma-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {
PrismaParser,
VisitorClassFactory,
getSchema,
} from '@mrleebo/prisma-ast';

export function getPrismaSchema(sourceCode: string) {
const parser = new PrismaParser({ nodeLocationTracking: 'full' });
const VisitorClass = VisitorClassFactory(parser);
const visitor = new VisitorClass();
const prismaSchema = getSchema(sourceCode, { parser, visitor });
return prismaSchema;
}
40 changes: 40 additions & 0 deletions src/common/regex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { isRegexOrRegexStr, toRegExp } from '#src/common/regex.js';

describe('isRegexOrRegexStr', () => {
it('should return true for RegExp instance', () => {
const regex = /[a-z]/;
expect(isRegexOrRegexStr(regex)).toBe(true);
});

it('should return true for string representing a regex', () => {
const regexStr = '/[0-9]+/';
expect(isRegexOrRegexStr(regexStr)).toBe(true);
});

it('should return false for other values', () => {
expect(isRegexOrRegexStr('test')).toBe(false);
expect(isRegexOrRegexStr(123)).toBe(false);
expect(isRegexOrRegexStr({})).toBe(false);
expect(isRegexOrRegexStr(null)).toBe(false);
expect(isRegexOrRegexStr(undefined)).toBe(false);
});
});

describe('toRegExp', () => {
it('should return the same RegExp instance if passed a RegExp', () => {
const regex = /[a-z]/;
expect(toRegExp(regex)).toBe(regex);
});

it('should convert a string representing a regex to a RegExp instance', () => {
const regexStr = '/[0-9]+/';
const expectedRegExp = new RegExp('[0-9]+');
expect(toRegExp(regexStr)).toEqual(expectedRegExp);
});

it('should create a RegExp from a string', () => {
const stringVal = 'test';
const expectedRegExp = new RegExp('^test$');
expect(toRegExp(stringVal)).toEqual(expectedRegExp);
});
});
13 changes: 10 additions & 3 deletions src/common/regex.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
export const isRegexOrRegexStr = (value: any) => {
return (
value instanceof RegExp || (value.startsWith('/') && value.endsWith('/'))
);
if (value == null) {
return false;
}
if (value instanceof RegExp) {
return true;
}
if (typeof value !== 'string') {
return false;
}
return value.startsWith('/') && value.endsWith('/');
};

export const toRegExp = (value: string | RegExp) => {
Expand Down
1 change: 1 addition & 0 deletions src/common/rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type Rule = { ruleConfig: RuleConfig; ruleDefinition: RuleDefinition };
*/
export type RuleContext<T extends NodeViolation> = {
fileName: string;
sourceCode: string;
report: (nodeViolation: T) => void;
};

Expand Down
33 changes: 12 additions & 21 deletions src/lint-prisma-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,36 +8,27 @@ import type { Rule } from '#src/common/rule.js';
import type { Violation } from '#src/common/violation.js';
import { lintPrismaSourceCode } from '#src/lint-prisma-source-code.js';

type FileViolations = { fileName: string; violations: Violation[] }[];
export type FileViolationList = {
fileName: string;
sourceCode: string;
violations: Violation[];
}[];

export const lintPrismaFiles = async ({
rules,
fileNames,
}: {
rules: Rule[];
fileNames: string[];
}): Promise<FileViolations> => {
const fileViolationList: FileViolations = [];
}): Promise<FileViolationList> => {
const fileViolationList: FileViolationList = [];
for (const fileName of fileNames) {
const violations = await lintPrismaFile({
rules,
fileName,
const filePath = path.resolve(fileName);
const sourceCode = await promisify(fs.readFile)(filePath, {
encoding: 'utf8',
});
fileViolationList.push({ fileName, violations });
const violations = lintPrismaSourceCode({ fileName, sourceCode, rules });
fileViolationList.push({ fileName, sourceCode, violations });
}
return fileViolationList;
};

const lintPrismaFile = async ({
rules,
fileName,
}: {
rules: Rule[];
fileName: string;
}): Promise<Violation[]> => {
const filePath = path.resolve(fileName);
const sourceCode = await promisify(fs.readFile)(filePath, {
encoding: 'utf8',
});
return lintPrismaSourceCode({ rules, fileName, sourceCode });
};

0 comments on commit 746cc89

Please sign in to comment.