Skip to content

Commit 92afd05

Browse files
author
Ben Callaghan
committed
feat(core): add new executor for dotnet-format
Add a new executor to lint and format projects using an external tool. Fixes #13
1 parent e2b1cfc commit 92afd05

File tree

10 files changed

+322
-0
lines changed

10 files changed

+322
-0
lines changed

packages/core/executors.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
"implementation": "./src/executors/publish/executor",
2121
"schema": "./src/executors/publish/schema.json",
2222
"description": "publish executor"
23+
},
24+
"format": {
25+
"implementation": "./src/executors/format/executor",
26+
"schema": "./src/executors/format/schema.json",
27+
"description": "Formats and lints a project using the dotnet-format tool"
2328
}
2429
},
2530
"builders": {
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { ExecutorContext } from '@nrwl/devkit';
2+
3+
import { promises as fs } from 'fs';
4+
5+
import { DotNetClient, mockDotnetFactory } from '@nx-dotnet/dotnet';
6+
import { rimraf } from '@nx-dotnet/utils';
7+
8+
import executor from './executor';
9+
import { FormatExecutorSchema } from './schema';
10+
11+
const options: FormatExecutorSchema = {
12+
check: true,
13+
verbosity: 'minimal',
14+
};
15+
16+
const root = process.cwd() + '/tmp';
17+
18+
jest.mock('../../../../dotnet/src/lib/core/dotnet.client');
19+
20+
describe('Format Executor', () => {
21+
let context: ExecutorContext;
22+
let dotnetClient: DotNetClient;
23+
24+
beforeEach(() => {
25+
context = {
26+
root: root,
27+
cwd: root,
28+
projectName: 'my-app',
29+
targetName: 'lint',
30+
workspace: {
31+
version: 2,
32+
projects: {
33+
'my-app': {
34+
root: `${root}/apps/my-app`,
35+
sourceRoot: `${root}/apps/my-app`,
36+
targets: {
37+
lint: {
38+
executor: '@nx-dotnet/core:format',
39+
},
40+
},
41+
},
42+
},
43+
},
44+
isVerbose: false,
45+
};
46+
dotnetClient = new DotNetClient(mockDotnetFactory());
47+
});
48+
49+
afterEach(async () => {
50+
await rimraf(root);
51+
});
52+
53+
it('detects no dotnet project', async () => {
54+
expect.assertions(1);
55+
try {
56+
await executor(options, context, dotnetClient);
57+
} catch (e) {
58+
console.log(e.message);
59+
expect(e.message).toMatch(
60+
"Unable to find a build-able project within project's source directory!",
61+
);
62+
}
63+
});
64+
65+
it('detects multiple dotnet projects', async () => {
66+
expect.assertions(1);
67+
68+
try {
69+
const directoryPath = `${root}/apps/my-app`;
70+
await fs.mkdir(directoryPath, { recursive: true });
71+
await Promise.all([
72+
fs.writeFile(`${directoryPath}/1.csproj`, ''),
73+
fs.writeFile(`${directoryPath}/2.csproj`, ''),
74+
]);
75+
} catch (e) {
76+
console.warn(e.message);
77+
}
78+
79+
try {
80+
await executor(options, context, dotnetClient);
81+
} catch (e) {
82+
console.log(e.message);
83+
expect(e.message).toMatch(
84+
"More than one build-able projects are contained within the project's source directory!",
85+
);
86+
}
87+
});
88+
89+
it('calls build when 1 project file is found', async () => {
90+
try {
91+
const directoryPath = `${root}/apps/my-app`;
92+
await fs.mkdir(directoryPath, { recursive: true });
93+
await Promise.all([fs.writeFile(`${directoryPath}/1.csproj`, '')]);
94+
} catch (e) {
95+
console.warn(e.message);
96+
}
97+
98+
const res = await executor(options, context, dotnetClient);
99+
expect(
100+
(dotnetClient as jest.Mocked<DotNetClient>).format,
101+
).toHaveBeenCalled();
102+
expect(res.success).toBeTruthy();
103+
});
104+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { ExecutorContext } from '@nrwl/devkit';
2+
import {
3+
DotNetClient,
4+
dotnetFactory,
5+
dotnetFormatFlags,
6+
} from '@nx-dotnet/dotnet';
7+
import {
8+
getExecutedProjectConfiguration,
9+
getProjectFileForNxProject,
10+
} from '@nx-dotnet/utils';
11+
import { FormatExecutorSchema } from './schema';
12+
13+
function normalizeOptions(
14+
options: FormatExecutorSchema,
15+
): Record<string, string | boolean | undefined> {
16+
const { diagnostics, include, exclude, check, fix, ...flags } = options;
17+
return {
18+
...flags,
19+
diagnostics: Array.isArray(diagnostics)
20+
? diagnostics.join(' ')
21+
: diagnostics,
22+
include: Array.isArray(include) ? include.join(' ') : include,
23+
exclude: Array.isArray(exclude) ? exclude.join(' ') : exclude,
24+
check: fix ? false : check,
25+
};
26+
}
27+
28+
export default async function runExecutor(
29+
options: FormatExecutorSchema,
30+
context: ExecutorContext,
31+
dotnetClient: DotNetClient = new DotNetClient(dotnetFactory()),
32+
) {
33+
const nxProjectConfiguration = getExecutedProjectConfiguration(context);
34+
const projectFilePath = await getProjectFileForNxProject(
35+
nxProjectConfiguration,
36+
);
37+
38+
const normalized = normalizeOptions(options);
39+
40+
dotnetClient.installTool('dotnet-format');
41+
dotnetClient.format(
42+
projectFilePath,
43+
Object.keys(options).map((x) => ({
44+
flag: x as dotnetFormatFlags,
45+
value: normalized[x],
46+
})),
47+
);
48+
49+
return {
50+
success: true,
51+
};
52+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export interface FormatExecutorSchema {
2+
noRestore?: boolean;
3+
fixWhitespace?: boolean;
4+
fixStyle?: string;
5+
diagnostics?: string | string[];
6+
include?: string[];
7+
exclude?: string[];
8+
check: boolean;
9+
report?: string;
10+
binarylog?: string;
11+
verbosity:
12+
| 'q'
13+
| 'quiet'
14+
| 'm'
15+
| 'minimal'
16+
| 'n'
17+
| 'normal'
18+
| 'd'
19+
| 'detailed'
20+
| 'diag'
21+
| 'diagnostic';
22+
fix?: boolean;
23+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
{
2+
"$schema": "http://json-schema.org/schema",
3+
"cli": "nx",
4+
"title": "Format executor",
5+
"description": "Formats and lints a project using the dotnet-format tool",
6+
"type": "object",
7+
"properties": {
8+
"noRestore": {
9+
"type": "boolean",
10+
"description": "Doesn't execute an implicit restore before formatting"
11+
},
12+
"fixWhitespace": {
13+
"type": "boolean",
14+
"alias": ["w"],
15+
"description": "Run whitespace formatting. Run by default when not applying fixes."
16+
},
17+
"fixStyle": {
18+
"type": "string",
19+
"alias": ["s"],
20+
"description": "Run code style analyzers and apply fixes."
21+
},
22+
"fixAnalyzers": {
23+
"type": "string",
24+
"alias": ["a"],
25+
"description": "Run 3rd party analyzers and apply fixes."
26+
},
27+
"diagnostics": {
28+
"anyOf": [
29+
{
30+
"type": "string",
31+
"description": "A space separated list of diagnostic ids to use as a filter when fixing code style or 3rd party analyzers."
32+
},
33+
{
34+
"type": "array",
35+
"description": "A list of diagnostic ids to use as a filter when fixing code style or 3rd party analyzers.",
36+
"items": {
37+
"type": "string"
38+
}
39+
}
40+
]
41+
},
42+
"include": {
43+
"type": "array",
44+
"description": "A list of relative file or folder paths to include in formatting. All files are formatted if empty",
45+
"items": {
46+
"type": "string"
47+
}
48+
},
49+
"exclude": {
50+
"type": "array",
51+
"description": "A list of relative file or folder paths to exclude from formatting.",
52+
"items": {
53+
"type": "string"
54+
}
55+
},
56+
"check": {
57+
"type": "boolean",
58+
"description": "Formats files without saving changes to disk. Terminates with a non-zero exit code if any files were formatted.",
59+
"default": true
60+
},
61+
"report": {
62+
"type": "string",
63+
"description": "Accepts a file path, which if provided, will produce a json report in the given directory."
64+
},
65+
"binarylog": {
66+
"type": "string",
67+
"description": "Log all project or solution load information to a binary log file."
68+
},
69+
"verbosity": {
70+
"type": "string",
71+
"description": "Set the verbosity level.",
72+
"enum": [
73+
"q",
74+
"quiet",
75+
"m",
76+
"minimal",
77+
"n",
78+
"normal",
79+
"d",
80+
"detailed",
81+
"diag",
82+
"diagnostic"
83+
],
84+
"default": "minimal"
85+
},
86+
"fix": {
87+
"type": "boolean",
88+
"description": "Formats files and saves changes to disk. Equivalent to setting --check=false."
89+
}
90+
},
91+
"required": []
92+
}

packages/dotnet/src/lib/core/dotnet.client.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import {
1010
buildKeyMap,
1111
dotnetAddPackageOptions,
1212
dotnetBuildOptions,
13+
dotnetFormatOptions,
1314
dotnetNewOptions,
1415
dotnetPublishOptions,
1516
dotnetRunOptions,
1617
dotnetTemplate,
1718
dotnetTestOptions,
19+
formatKeyMap,
1820
newKeyMap,
1921
publishKeyMap,
2022
testKeyMap,
@@ -130,6 +132,20 @@ export class DotNetClient {
130132
return this.logAndExecute(cmd);
131133
}
132134

135+
format(project: string, parameters?: dotnetFormatOptions): Buffer {
136+
let cmd = `${this.cliCommand.command} format ${project}`;
137+
if (parameters) {
138+
parameters = swapArrayFieldValueUsingMap(
139+
parameters,
140+
'flag',
141+
formatKeyMap,
142+
);
143+
const paramString = parameters ? getParameterString(parameters) : '';
144+
cmd = `${cmd} ${paramString}`;
145+
}
146+
return this.logAndExecute(cmd);
147+
}
148+
133149
private logAndExecute(cmd: string): Buffer {
134150
console.log(`Executing Command: ${cmd}`);
135151
return execSync(cmd, { stdio: 'inherit' });
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export type dotnetFormatFlags =
2+
| 'noRestore'
3+
// Deliberately excluding the folder option, as the csproj file is always used as the workspace.
4+
| 'fixWhitespace'
5+
| 'fixStyle'
6+
| 'fixAnalyzers'
7+
| 'diagnostics'
8+
| 'include'
9+
| 'exclude'
10+
| 'check'
11+
| 'report'
12+
| 'binarylog'
13+
| 'verbosity';
14+
// Deliberately excluding the version option, as it doesn't perform any actual formatting.
15+
16+
export const formatKeyMap: Partial<{ [key in dotnetFormatFlags]: string }> = {
17+
noRestore: 'no-restore',
18+
fixWhitespace: 'fix-whitespace',
19+
fixStyle: 'fix-style',
20+
fixAnalyzers: 'fix-analyzers',
21+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { dotnetFormatFlags } from './dotnet-format-flags';
2+
3+
export type dotnetFormatOptions = {
4+
flag: dotnetFormatFlags;
5+
value?: string | boolean;
6+
}[];
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './dotnet-format-flags';
2+
export * from './dotnet-format-options';

packages/dotnet/src/lib/models/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from './dotnet-run';
44
export * from './dotnet-test';
55
export * from './dotnet-add-package';
66
export * from './dotnet-publish';
7+
export * from './dotnet-format';

0 commit comments

Comments
 (0)