Skip to content

Commit 6c3dc73

Browse files
committed
feat: Add matchIndentation option
1 parent 4537d7f commit 6c3dc73

File tree

7 files changed

+287
-11
lines changed

7 files changed

+287
-11
lines changed

.eslintrc.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
module.exports = {
22
extends: ['canonical/auto', 'canonical/node'],
3-
ignorePatterns: ['dist', 'package-lock.json'],
3+
ignorePatterns: ['dist', 'package-lock.json', 'node_modules'],
44
root: true,
55
rules: {
6+
'complexity': 0,
67
'no-template-curly-in-string': 0,
78
'node/no-sync': 0,
89
},

src/rules/format.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { indentString, stripIndent } from '../utilities/indent';
12
import isSqlQuery from '../utilities/isSqlQuery';
23
import { generate } from 'astring';
34
import { format } from 'pg-formatter';
@@ -11,6 +12,7 @@ const create = (context) => {
1112
const ignoreInline = pluginOptions.ignoreInline !== false;
1213
const ignoreTagless = pluginOptions.ignoreTagless !== false;
1314
const ignoreStartWithNewLine = pluginOptions.ignoreStartWithNewLine !== false;
15+
const matchIndentation = pluginOptions.matchIndentation !== false;
1416

1517
return {
1618
TemplateLiteral(node) {
@@ -41,18 +43,60 @@ const create = (context) => {
4143
return;
4244
}
4345

44-
if (ignoreInline && !literal.includes('\n')) {
46+
const eolMatch = literal.match(/\r?\n/u);
47+
if (ignoreInline && !eolMatch) {
4548
return;
4649
}
4750

48-
let formatted = format(literal, context.options[1]);
51+
const [eol = '\n'] = eolMatch || [];
52+
53+
const sourceCode = context.getSourceCode();
54+
const startLine = sourceCode.lines[node.loc.start.line - 1];
55+
const marginMatch = startLine.match(/^(\s*)\S/u);
56+
const parentMargin = marginMatch ? marginMatch[1] : '';
57+
58+
const pgFormatterOptions = { ...context.options[1] };
59+
const { spaces = 4, tabs = false } = pgFormatterOptions;
60+
let indent;
61+
if (tabs) {
62+
indent = `\t`;
63+
} else if (spaces) {
64+
indent = ' '.repeat(spaces);
65+
} else {
66+
const usesTabs = parentMargin.startsWith('\t');
67+
pgFormatterOptions.tabs = usesTabs;
68+
pgFormatterOptions.spaces = usesTabs ? 4 : 2;
69+
indent = usesTabs ? `\t` : ' ';
70+
}
71+
72+
let formatted = format(literal, pgFormatterOptions);
73+
74+
if (matchIndentation) {
75+
const stripped = stripIndent(formatted);
76+
const trimmed = stripped.replaceAll(
77+
new RegExp(`^${eol}|${eol}[ \t]*$`, 'gu'),
78+
'',
79+
);
80+
const formattedLines = trimmed.split(eol);
81+
const indented =
82+
formattedLines
83+
.map((line) => {
84+
return parentMargin + indentString(line, 1, { indent });
85+
})
86+
.join(eol) +
87+
// The last line has an eol to make the backtick line up
88+
eol +
89+
parentMargin;
90+
91+
formatted = indented;
92+
}
4993

5094
if (
5195
ignoreStartWithNewLine &&
52-
literal.startsWith('\n') &&
53-
!formatted.startsWith('\n')
96+
literal.startsWith(eol) &&
97+
!formatted.startsWith(eol)
5498
) {
55-
formatted = '\n' + formatted;
99+
formatted = eol + formatted;
56100
}
57101

58102
if (formatted !== literal) {
@@ -117,6 +161,10 @@ export = {
117161
default: true,
118162
type: 'boolean',
119163
},
164+
matchIndentation: {
165+
default: true,
166+
type: 'boolean',
167+
},
120168
},
121169
type: 'object',
122170
},

src/utilities/indent.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
type IndentOptions = {
2+
includeEmptyLines?: boolean;
3+
indent?: string;
4+
};
5+
6+
const minIndent = function (string: string): number {
7+
const match = string.match(/^[\t ]*(?=\S)/gmu);
8+
9+
if (!match) {
10+
return 0;
11+
}
12+
13+
let min = Number.POSITIVE_INFINITY;
14+
15+
for (const indent of match) {
16+
min = Math.min(min, indent.length);
17+
}
18+
19+
return min;
20+
};
21+
22+
export const stripIndent = function (string: string): string {
23+
const indent = minIndent(string);
24+
25+
if (indent === 0) {
26+
return string;
27+
}
28+
29+
const regex = new RegExp(`^[ \\t]{${indent}}`, 'gmu');
30+
31+
return string.replace(regex, '');
32+
};
33+
34+
export const indentString = function (
35+
string: string,
36+
count: number = 1,
37+
options: IndentOptions = {},
38+
): string {
39+
const { indent = ' ', includeEmptyLines = false } = options;
40+
41+
if (count === 0) {
42+
return string;
43+
}
44+
45+
const regex = includeEmptyLines ? /^/gmu : /^(?!\s*$)/gmu;
46+
47+
return string.replace(regex, indent.repeat(count));
48+
};

test/rules/assertions/format.ts

Lines changed: 120 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export default {
1313
ignoreTagless: false,
1414
},
1515
],
16-
output: '`\nSELECT\n 1\n`',
16+
output: '`\n SELECT\n 1\n`',
1717
},
1818
{
1919
code: '`SELECT 2`',
@@ -31,7 +31,7 @@ export default {
3131
spaces: 2,
3232
},
3333
],
34-
output: '`\nSELECT\n 2\n`',
34+
output: '`\n SELECT\n 2\n`',
3535
},
3636
{
3737
code: 'sql.unsafe`SELECT 3`',
@@ -45,7 +45,7 @@ export default {
4545
ignoreInline: false,
4646
},
4747
],
48-
output: 'sql.unsafe`\nSELECT\n 3\n`',
48+
output: 'sql.unsafe`\n SELECT\n 3\n`',
4949
},
5050
{
5151
code: 'sql.type()`SELECT 3`',
@@ -59,7 +59,7 @@ export default {
5959
ignoreInline: false,
6060
},
6161
],
62-
output: 'sql.type()`\nSELECT\n 3\n`',
62+
output: 'sql.type()`\n SELECT\n 3\n`',
6363
},
6464
{
6565
code: "`SELECT ${'foo'} FROM ${'bar'}`",
@@ -74,10 +74,116 @@ export default {
7474
ignoreTagless: false,
7575
},
7676
],
77-
output: "`\nSELECT\n ${'foo'}\nFROM\n ${'bar'}\n`",
77+
output: "`\n SELECT\n ${'foo'}\n FROM\n ${'bar'}\n`",
78+
},
79+
{
80+
code: "\t\t`SELECT ${'foo'} FROM ${'bar'}`",
81+
errors: [
82+
{
83+
message: 'Format the query',
84+
},
85+
],
86+
options: [
87+
{
88+
ignoreInline: false,
89+
ignoreTagless: false,
90+
},
91+
],
92+
output:
93+
"\t\t`\n\t\t SELECT\n\t\t ${'foo'}\n\t\t FROM\n\t\t ${'bar'}\n\t\t`",
94+
},
95+
{
96+
code: '\tconst s = sql`SELECT\n1\nFROM\ntable`',
97+
errors: [
98+
{
99+
message: 'Format the query',
100+
},
101+
],
102+
options: [
103+
{},
104+
{
105+
spaces: 2,
106+
},
107+
],
108+
output:
109+
'\tconst s = sql`\n\t SELECT\n\t 1\n\t FROM\n\t table\n\t`',
110+
},
111+
{
112+
code: '\tconst s = sql`SELECT 1 FROM table`',
113+
errors: [
114+
{
115+
message: 'Format the query',
116+
},
117+
],
118+
options: [
119+
{
120+
ignoreInline: false,
121+
},
122+
{
123+
tabs: true,
124+
},
125+
],
126+
output:
127+
'\tconst s = sql`\n\t\tSELECT\n\t\t\t1\n\t\tFROM\n\t\t\ttable\n\t`',
128+
},
129+
{
130+
code: '\tconst s = sql`SELECT 1 FROM table`',
131+
errors: [
132+
{
133+
message: 'Format the query',
134+
},
135+
],
136+
options: [
137+
{
138+
ignoreInline: false,
139+
},
140+
{
141+
spaces: 0,
142+
tabs: false,
143+
},
144+
],
145+
output:
146+
'\tconst s = sql`\n\t\tSELECT\n\t\t\t1\n\t\tFROM\n\t\t\ttable\n\t`',
147+
},
148+
{
149+
code: ' const s = sql`SELECT 1 FROM table`',
150+
errors: [
151+
{
152+
message: 'Format the query',
153+
},
154+
],
155+
options: [
156+
{
157+
ignoreInline: false,
158+
},
159+
{
160+
spaces: 0,
161+
tabs: false,
162+
},
163+
],
164+
output:
165+
' const s = sql`\n SELECT\n 1\n FROM\n table\n `',
166+
},
167+
{
168+
code: '\tsql`SELECT 1 FROM table`',
169+
errors: [
170+
{
171+
message: 'Format the query',
172+
},
173+
],
174+
options: [
175+
{
176+
ignoreInline: false,
177+
matchIndentation: false,
178+
},
179+
],
180+
output: '\tsql`\nSELECT\n 1\nFROM\n table\n`',
78181
},
79182
],
80183
valid: [
184+
{
185+
code: 'sql`SELECT 1`',
186+
},
81187
{
82188
code: 'sql`SELECT 1`',
83189
options: [
@@ -104,5 +210,14 @@ export default {
104210
},
105211
],
106212
},
213+
{
214+
code: '\tconst s = sql`\n\t SELECT\n\t 1\n\t FROM\n\t table\n\t`',
215+
options: [
216+
{},
217+
{
218+
spaces: 2,
219+
},
220+
],
221+
},
107222
],
108223
};

test/rules/assertions/noUnsafeQuery.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,8 @@ export default {
5353
{
5454
code: "sql`SELECT ${'foo'}`",
5555
},
56+
{
57+
code: 'sql``',
58+
},
5659
],
5760
};

test/utilities/indent.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/* global describe */
2+
/* global it */
3+
4+
import { indentString, stripIndent } from '../../src/utilities/indent';
5+
import assert from 'node:assert';
6+
7+
describe('indentString', () => {
8+
it('should indent each line in a string', () => {
9+
assert(indentString('foo\nbar') === ' foo\n bar');
10+
assert(indentString('foo\nbar', 1) === ' foo\n bar');
11+
assert(indentString('foo\r\nbar', 1) === ' foo\r\n bar');
12+
assert(indentString('foo\nbar', 4) === ' foo\n bar');
13+
});
14+
it('should not indent whitespace only lines', () => {
15+
assert(indentString('foo\nbar\n', 1) === ' foo\n bar\n');
16+
assert(
17+
indentString('foo\nbar\n', 1, { includeEmptyLines: false }) ===
18+
' foo\n bar\n',
19+
);
20+
});
21+
it('should indent every line if options.includeEmptyLines is true', () => {
22+
assert(
23+
indentString('foo\n\nbar\n\t', 1, { includeEmptyLines: true }) ===
24+
' foo\n \n bar\n \t',
25+
);
26+
});
27+
it('should indent with leading whitespace', () => {
28+
assert(
29+
indentString('foo\n\nbar\n\t', 1, { includeEmptyLines: true }) ===
30+
' foo\n \n bar\n \t',
31+
);
32+
});
33+
it('should indent with custom string', () => {
34+
assert(indentString('foo\nbar\n', 1, { indent: '♥' }) === '♥foo\n♥bar\n');
35+
});
36+
it('should not indent when count is 0', () => {
37+
assert(indentString('foo\nbar\n', 0) === 'foo\nbar\n');
38+
});
39+
});
40+
41+
describe('stripIndent', () => {
42+
it('should strip leading whitespace from each line in a string', () => {
43+
assert(stripIndent('') === '');
44+
assert(stripIndent('\nunicorn\n') === '\nunicorn\n');
45+
assert(stripIndent('\n unicorn\n') === '\nunicorn\n');
46+
assert(
47+
stripIndent(
48+
'\t\t<!doctype html>\n\t\t<html>\n\t\t\t<body>\n\n\n\n\t\t\t\t<h1>Hello world!</h1>\n\t\t\t</body>\n\t\t</html>',
49+
) ===
50+
'<!doctype html>\n<html>\n\t<body>\n\n\n\n\t\t<h1>Hello world!</h1>\n\t</body>\n</html>',
51+
);
52+
assert(
53+
stripIndent('\n\t\n\t\tunicorn\n\n\n\n\t\t\tunicorn') ===
54+
'\n\t\nunicorn\n\n\n\n\tunicorn',
55+
'ignore whitespace only lines',
56+
);
57+
});
58+
});

test/utilities/isSqlQuery.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,7 @@ describe('isSqlQuery', () => {
1515
assert(!isSqlQuery('foo bar'));
1616
assert(!isSqlQuery('foo SELECT FROM bar'));
1717
});
18+
it('ignores falsy values', () => {
19+
assert(!isSqlQuery(''));
20+
});
1821
});

0 commit comments

Comments
 (0)