Skip to content

Commit d1f9fef

Browse files
authoredSep 27, 2024··
Add HTML Element close tag auto-insertion support (#501)
* Rearchitect OnTypeFormattingProvider for extensibility * Add HTML Element close tag auto-insertion support Fixes #499 * Add onEnterRules for HTML so that the indentation is correct after pressing enter * Complete inside branches

18 files changed

+688
-186
lines changed
 

‎.changeset/gentle-news-deny.md

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'theme-check-vscode': minor
3+
---
4+
5+
Add proper HTML tag `onEnterRules`
6+
7+
```liquid
8+
{% # type this, then press enter %}
9+
<div>|</div>
10+
11+
{% # you get this, with cursor at | %}
12+
<div>
13+
|
14+
</div>
15+
```

‎.changeset/gentle-rocks-rule.md

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@shopify/theme-language-server-common': minor
3+
'theme-check-vscode': minor
4+
---
5+
6+
Add support for HTML Element close tag auto-insertion
7+
8+
```liquid
9+
{% # type this %}
10+
<div>
11+
{% # get this, with cursor at | %}
12+
<div>|</div>
13+
```

‎.changeset/sixty-gorillas-rule.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/liquid-html-parser': patch
3+
---
4+
5+
(Internal) Add `unclosed` node information to LiquidHTMLASTError

‎ThirdPartyNotices.txt

+28-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ The theme-language-server project incorporates third party material from the pro
55
1. vscode-languageserver-node (https://github.com/microsoft/vscode-languageserver-node)
66
2. @yetoapp/jsonc (https://github.com/yettoapp/lezer-jsonc)
77
3. @lezer/json (https://github.com/lezer-parser/json)
8-
3. @codemirror/lang-json (https://github.com/codemirror/lang-json)
8+
4. @codemirror/lang-json (https://github.com/codemirror/lang-json)
9+
5. monaco-editor (https://github.com/microsoft/monaco-editor)
910

1011
%% vscode-languageserver-node NOTICES, INFORMATION, AND LICENSE BEGIN HERE
1112
=========================================
@@ -106,3 +107,29 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
106107
THE SOFTWARE.
107108
=========================================
108109
END OF @codemirror/lang-json NOTICES, INFORMATION, AND LICENSE
110+
111+
%% monaco-editor NOTICES, INFORMATION, AND LICENSE BEGIN HERE
112+
=========================================
113+
The MIT License (MIT)
114+
115+
Copyright (c) 2016 - present Microsoft Corporation
116+
117+
Permission is hereby granted, free of charge, to any person obtaining a copy
118+
of this software and associated documentation files (the "Software"), to deal
119+
in the Software without restriction, including without limitation the rights
120+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
121+
copies of the Software, and to permit persons to whom the Software is
122+
furnished to do so, subject to the following conditions:
123+
124+
The above copyright notice and this permission notice shall be included in all
125+
copies or substantial portions of the Software.
126+
127+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
128+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
129+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
130+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
131+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
132+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
133+
SOFTWARE.
134+
=========================================
135+
END OF monaco-editor NOTICES, INFORMATION, AND LICENSE

‎packages/liquid-html-parser/src/errors.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { MatchResult } from 'ohm-js';
21
import lineColumn from 'line-column';
2+
import { MatchResult } from 'ohm-js';
3+
import { NodeTypes, Position } from './types';
34

45
interface LineColPosition {
56
line: number;
@@ -35,12 +36,22 @@ export class LiquidHTMLCSTParsingError extends SyntaxError {
3536
}
3637
}
3738

39+
export type UnclosedNode = { type: NodeTypes; name: string; blockStartPosition: Position };
40+
3841
export class LiquidHTMLASTParsingError extends SyntaxError {
3942
loc?: { start: LineColPosition; end: LineColPosition };
43+
unclosed: UnclosedNode | null;
4044

41-
constructor(message: string, source: string, startIndex: number, endIndex: number) {
45+
constructor(
46+
message: string,
47+
source: string,
48+
startIndex: number,
49+
endIndex: number,
50+
unclosed?: UnclosedNode,
51+
) {
4252
super(message);
4353
this.name = 'LiquidHTMLParsingError';
54+
this.unclosed = unclosed ?? null;
4455

4556
const lc = lineColumn(source);
4657
const start = lc.fromIndex(startIndex);

‎packages/liquid-html-parser/src/stage-2-ast.ts

+18-3
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ import {
7575
} from './stage-1-cst';
7676
import { Comparators, NamedTags, NodeTypes, nonTraversableProperties, Position } from './types';
7777
import { assertNever, deepGet, dropLast } from './utils';
78-
import { LiquidHTMLASTParsingError } from './errors';
78+
import { LiquidHTMLASTParsingError, UnclosedNode } from './errors';
7979
import { TAGS_WITHOUT_MARKUP } from './grammar';
8080
import { toLiquidCST } from './stage-1-cst';
8181

@@ -941,6 +941,7 @@ class ASTBuilder {
941941
this.source,
942942
this.parent.position.start,
943943
node.locEnd,
944+
getUnclosed(this.parent),
944945
);
945946
}
946947
}
@@ -1020,9 +1021,9 @@ function isLiquidBranch(node: LiquidHtmlNode | undefined): node is LiquidBranchN
10201021
return !!node && node.type === NodeTypes.LiquidBranch;
10211022
}
10221023

1023-
function getName(
1024+
export function getName(
10241025
node: ConcreteLiquidTagClose | ConcreteHtmlTagClose | ParentNode | undefined,
1025-
): string | LiquidVariableOutput | null {
1026+
): string | null {
10261027
if (!node) return null;
10271028
switch (node.type) {
10281029
case NodeTypes.HtmlElement:
@@ -1075,6 +1076,7 @@ export function cstToAst(
10751076
builder.source,
10761077
builder.source.length - 1,
10771078
builder.source.length,
1079+
getUnclosed(builder.parent, builder.grandparent),
10781080
);
10791081
}
10801082

@@ -1460,6 +1462,7 @@ function toNamedLiquidTag(
14601462
...liquidTagBaseAttributes(node),
14611463
name: node.name,
14621464
markup: toConditionalExpression(node.markup),
1465+
blockEndPosition: { start: -1, end: -1 },
14631466
children: [],
14641467
};
14651468
}
@@ -1999,3 +2002,15 @@ export function isLiquidHtmlNode(value: any): value is LiquidHtmlNode {
19992002
NodeTypes.hasOwnProperty(value.type)
20002003
);
20012004
}
2005+
2006+
function getUnclosed(node?: ParentNode, parentNode?: ParentNode): UnclosedNode | undefined {
2007+
if (!node) return undefined;
2008+
if (getName(node) === null && parentNode) {
2009+
node = parentNode;
2010+
}
2011+
return {
2012+
type: node.type,
2013+
name: getName(node) ?? '',
2014+
blockStartPosition: 'blockStartPosition' in node ? node.blockStartPosition : node.position,
2015+
};
2016+
}

‎packages/theme-language-server-common/src/ClientCapabilities.ts

+4
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export class ClientCapabilities {
2727
return !!this.capabilities?.workspace?.didChangeConfiguration?.dynamicRegistration;
2828
}
2929

30+
get hasShowDocumentSupport() {
31+
return !!this.capabilities?.window?.showDocument;
32+
}
33+
3034
initializationOption<T>(key: string, defaultValue: T): T {
3135
// { 'themeCheck.checkOnSave': true }
3236
const direct = this.initializationOptions?.[key];

‎packages/theme-language-server-common/src/documents/DocumentManager.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { TextDocument } from 'vscode-languageserver-textdocument';
33
import { URI } from 'vscode-languageserver-types';
44
import { toAbsolutePath } from '../utils';
55

6-
export type AugmentedSourceCode = SourceCode<SourceCodeType> & {
6+
export type AugmentedSourceCode<SCT extends SourceCodeType = SourceCodeType> = SourceCode<SCT> & {
77
textDocument: TextDocument;
88
uri: URI;
99
};

‎packages/theme-language-server-common/src/formatting/OnTypeFormattingProvider.spec.ts

+4-73
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { describe, beforeEach, it, expect, assert } from 'vitest';
2-
import { OnTypeFormattingProvider } from './OnTypeFormattingProvider';
3-
import { DocumentManager } from '../documents';
1+
import { beforeEach, describe, expect, it } from 'vitest';
42
import { DocumentOnTypeFormattingParams } from 'vscode-languageserver';
5-
import { Position, TextEdit, Range } from 'vscode-languageserver-protocol';
6-
import { TextDocument } from 'vscode-languageserver-textdocument';
3+
import { Position } from 'vscode-languageserver-protocol';
4+
import { DocumentManager } from '../documents';
5+
import { OnTypeFormattingProvider } from './OnTypeFormattingProvider';
76

87
const options: DocumentOnTypeFormattingParams['options'] = {
98
insertSpaces: true,
@@ -59,74 +58,6 @@ describe('Module: OnTypeFormattingProvider', () => {
5958
expect(result).toBeNull();
6059
});
6160

62-
it('should return a TextEdit to insert a space after "{{" in "{{ }}"', async () => {
63-
const params: DocumentOnTypeFormattingParams = {
64-
textDocument: { uri: 'file:///path/to/document.liquid' },
65-
position: Position.create(0, 2),
66-
ch: '{',
67-
options,
68-
};
69-
70-
documentManager.open(params.textDocument.uri, '{{ }}', 1);
71-
const document = documentManager.get(params.textDocument.uri)?.textDocument;
72-
assert(document);
73-
74-
const result = await onTypeFormattingProvider.onTypeFormatting(params);
75-
assert(result);
76-
expect(TextDocument.applyEdits(document, result)).to.equal('{{ }}');
77-
});
78-
79-
it('should return a TextEdit to insert a space after "{%" in "{% %}"', async () => {
80-
const params: DocumentOnTypeFormattingParams = {
81-
textDocument: { uri: 'file:///path/to/document.liquid' },
82-
position: Position.create(0, 2),
83-
ch: '%',
84-
options,
85-
};
86-
87-
documentManager.open(params.textDocument.uri, '{% %}', 1);
88-
const document = documentManager.get(params.textDocument.uri)?.textDocument;
89-
assert(document);
90-
91-
const result = await onTypeFormattingProvider.onTypeFormatting(params);
92-
assert(result);
93-
expect(TextDocument.applyEdits(document, result)).to.equal('{% %}');
94-
});
95-
96-
it('should return a TextEdit to replace and insert characters in "{{ - }}"', async () => {
97-
const params: DocumentOnTypeFormattingParams = {
98-
textDocument: { uri: 'file:///path/to/document.liquid' },
99-
position: Position.create(0, 4),
100-
ch: '-',
101-
options,
102-
};
103-
104-
documentManager.open(params.textDocument.uri, '{{ - }}', 1);
105-
const document = documentManager.get(params.textDocument.uri)?.textDocument;
106-
assert(document);
107-
108-
const result = await onTypeFormattingProvider.onTypeFormatting(params);
109-
assert(result);
110-
expect(TextDocument.applyEdits(document, result)).to.equal('{{- -}}');
111-
});
112-
113-
it('should return a TextEdit to replace and insert characters in "{% - %}"', async () => {
114-
const params: DocumentOnTypeFormattingParams = {
115-
textDocument: { uri: 'file:///path/to/document.liquid' },
116-
position: Position.create(0, 4),
117-
ch: '-',
118-
options,
119-
};
120-
121-
documentManager.open(params.textDocument.uri, '{% - %}', 1);
122-
const document = documentManager.get(params.textDocument.uri)?.textDocument;
123-
assert(document);
124-
125-
const result = await onTypeFormattingProvider.onTypeFormatting(params);
126-
assert(result);
127-
expect(TextDocument.applyEdits(document, result)).to.equal('{%- -%}');
128-
});
129-
13061
it('should return null for characters not matching any case', async () => {
13162
const params: DocumentOnTypeFormattingParams = {
13263
textDocument: { uri: 'file:///path/to/document.liquid' },
Original file line numberDiff line numberDiff line change
@@ -1,110 +1,26 @@
1-
import { DocumentManager } from '../documents';
21
import { DocumentOnTypeFormattingParams } from 'vscode-languageserver';
3-
import { Range, Position, TextEdit } from 'vscode-languageserver-protocol';
2+
import { DocumentManager } from '../documents';
3+
import { BracketsAutoclosingOnTypeFormattingProvider } from './providers/BracketsAutoclosingOnTypeFormattingProvider';
4+
import { HtmlElementAutoclosingOnTypeFormattingProvider } from './providers/HtmlElementAutoclosingOnTypeFormattingProvider';
5+
import { BaseOnTypeFormattingProvider, SetCursorPosition } from './types';
46

57
export class OnTypeFormattingProvider {
6-
constructor(public documentManager: DocumentManager) {}
8+
private providers: BaseOnTypeFormattingProvider[];
9+
10+
constructor(
11+
public documentManager: DocumentManager,
12+
public setCursorPosition: SetCursorPosition = async () => {},
13+
) {
14+
this.providers = [
15+
new BracketsAutoclosingOnTypeFormattingProvider(),
16+
new HtmlElementAutoclosingOnTypeFormattingProvider(setCursorPosition),
17+
];
18+
}
719

8-
/**
9-
* This very complex piece of code here exists to provide a good autoclosing UX.
10-
*
11-
* The story is kind of long so here goes...
12-
*
13-
* What we want:
14-
* 1. Basic autoclosing of {{, {% with the corresponding pair (and spaces)
15-
* - user types: {{
16-
* - user sees: {{ | }} (with cursor position at |)
17-
* 2. Autoclosing of {{- with -}}, {%- with -%}
18-
* - user types: {{-
19-
* - user sees: {{- | -}} (with cursor at |)
20-
* 3. User adds whitespace stripping on one side of the braces of an existing tag
21-
* - user types: - at | in `{{| drop }}`
22-
* - user sees: {{- drop }}
23-
*
24-
* Why we can't do it with autoclosingPairs:
25-
* - VS Code's settings accepts autoclosingPairs and autocloseBefore
26-
* - autoclosingPairs is a set of pairs that should be autoclosed (e.g. ['{%', '%}'])
27-
* - autocloseBefore is a character set of 'allowed next characters' that would cause a closing pair
28-
* - If we put a space (' ') the autoclosingPairs set, then (3) from above becomes funky:
29-
* - assume autoclosingPairs = {|}, {{|}}, {{ | }}
30-
* - user types: a space at | in `{{| drop }}`
31-
* - user sees: {{ }}drop }}
32-
* - This happens because the space is an autocloseBefore character, it sees a space after the cursor
33-
* so it closes '{{ ' with ' }}' at the cursor position, resulting in '{{ }}drop }}'
34-
* - Something similar happens if we include the `-` in the autoclosing pairs
35-
* - This is annoying!
36-
*
37-
* So our solution is the following:
38-
* 1. We change the pairs to include the closing space (this way our cursor remains where we want it to be)
39-
* - {{| }}
40-
* - {%| %}
41-
* 2. We add this OnTypeFormattingProvider that does the following "fixes":
42-
* - {{| }} into {{ | }}
43-
* - {{ -| }} into {{- | -}}
44-
* - {%| %} into {% | %}
45-
* - {% -| %} into {%- | -%}
46-
*
47-
* This lets us avoid the unnecessary close and accomplish 1, 2 and 3 :)
48-
*
49-
* Fallback for editor.onTypeFormatting: false is to let the user type the `-` on both sides manually
50-
*/
5120
async onTypeFormatting(params: DocumentOnTypeFormattingParams) {
5221
const document = this.documentManager.get(params.textDocument.uri);
5322
if (!document) return null;
54-
const textDocument = document.textDocument;
55-
const ch = params.ch;
56-
// position is position of cursor so 1 ahead of char
57-
const { line, character } = params.position;
58-
// This is an early return to avoid doing currentLine.at(-1);
59-
if ((ch === ' ' && character <= 2) || character <= 1) return null;
60-
const currentLineRange = Range.create(Position.create(line, 0), Position.create(line + 1, 0));
61-
const currentLine = textDocument.getText(currentLineRange);
62-
const charIdx = ch === ' ' ? character - 2 : character - 1;
63-
const char = currentLine.at(charIdx);
64-
switch (char) {
65-
// here we fix {{| }} with {{ | }}
66-
// here we fix {%| %} with {% | %}
67-
case '{':
68-
case '%': {
69-
const chars = currentLine.slice(charIdx - 1, charIdx + 4);
70-
if (chars === '{{ }}' || chars === '{% %}') {
71-
return [TextEdit.insert(Position.create(line, charIdx + 1), ' ')];
72-
}
73-
}
74-
75-
// here we fix {{ -| }} to {{- | -}}
76-
// here we fix {% -| }} to {%- | -%}
77-
case '-': {
78-
// remember 0-index means 4th char
79-
if (charIdx < 3) return null;
80-
81-
const chars = currentLine.slice(charIdx - 3, charIdx + 4);
82-
if (chars === '{{ - }}' || chars === '{% - %}') {
83-
// Here we're being clever and doing the {{- -}} if the first character
84-
// you type is a `-`, leaving your cursor in the middle :)
85-
return [
86-
// Start with
87-
// {{ - }}
88-
// ^ start replace
89-
// ^ end replace (excluded)
90-
// Replace with '- ', get
91-
// {{- }}
92-
TextEdit.replace(
93-
Range.create(Position.create(line, charIdx - 1), Position.create(line, charIdx + 1)),
94-
'- ',
95-
),
96-
// Start with
97-
// {{ - }}
98-
// ^ char
99-
// ^ insertion point
100-
// Insert ' ' , get
101-
// {{ - -}}
102-
// Both together and you get {{- -}} with your cursor in the middle
103-
TextEdit.insert(Position.create(line, charIdx + 2), '-'),
104-
];
105-
}
106-
}
107-
}
108-
return null;
23+
const results = this.providers.map((provider) => provider.onTypeFormatting(document, params));
24+
return results.find((result) => result !== null) ?? null;
10925
}
11026
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { assert, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
2+
import { DocumentOnTypeFormattingParams } from 'vscode-languageserver';
3+
import { Position } from 'vscode-languageserver-protocol';
4+
import { TextDocument } from 'vscode-languageserver-textdocument';
5+
import { DocumentManager } from '../../documents';
6+
import { OnTypeFormattingProvider } from '../OnTypeFormattingProvider';
7+
8+
const options: DocumentOnTypeFormattingParams['options'] = {
9+
insertSpaces: true,
10+
tabSize: 2,
11+
};
12+
13+
describe('Module: BracketsAutoclosingOnTypeFormattingProvider', () => {
14+
let documentManager: DocumentManager;
15+
let onTypeFormattingProvider: OnTypeFormattingProvider;
16+
17+
beforeEach(() => {
18+
documentManager = new DocumentManager();
19+
onTypeFormattingProvider = new OnTypeFormattingProvider(documentManager);
20+
});
21+
22+
it('should return a TextEdit to insert a space after "{{" in "{{ }}"', async () => {
23+
const params: DocumentOnTypeFormattingParams = {
24+
textDocument: { uri: 'file:///path/to/document.liquid' },
25+
position: Position.create(0, 2),
26+
ch: '{',
27+
options,
28+
};
29+
30+
documentManager.open(params.textDocument.uri, '{{ }}', 1);
31+
const document = documentManager.get(params.textDocument.uri)?.textDocument;
32+
assert(document);
33+
34+
const result = await onTypeFormattingProvider.onTypeFormatting(params);
35+
assert(result);
36+
expect(TextDocument.applyEdits(document, result)).to.equal('{{ }}');
37+
});
38+
39+
it('should return a TextEdit to insert a space after "{%" in "{% %}"', async () => {
40+
const params: DocumentOnTypeFormattingParams = {
41+
textDocument: { uri: 'file:///path/to/document.liquid' },
42+
position: Position.create(0, 2),
43+
ch: '%',
44+
options,
45+
};
46+
47+
documentManager.open(params.textDocument.uri, '{% %}', 1);
48+
const document = documentManager.get(params.textDocument.uri)?.textDocument;
49+
assert(document);
50+
51+
const result = await onTypeFormattingProvider.onTypeFormatting(params);
52+
assert(result);
53+
expect(TextDocument.applyEdits(document, result)).to.equal('{% %}');
54+
});
55+
56+
it('should return a TextEdit to replace and insert characters in "{{ - }}"', async () => {
57+
const params: DocumentOnTypeFormattingParams = {
58+
textDocument: { uri: 'file:///path/to/document.liquid' },
59+
position: Position.create(0, 4),
60+
ch: '-',
61+
options,
62+
};
63+
64+
documentManager.open(params.textDocument.uri, '{{ - }}', 1);
65+
const document = documentManager.get(params.textDocument.uri)?.textDocument;
66+
assert(document);
67+
68+
const result = await onTypeFormattingProvider.onTypeFormatting(params);
69+
assert(result);
70+
expect(TextDocument.applyEdits(document, result)).to.equal('{{- -}}');
71+
});
72+
73+
it('should return a TextEdit to replace and insert characters in "{% - %}"', async () => {
74+
const params: DocumentOnTypeFormattingParams = {
75+
textDocument: { uri: 'file:///path/to/document.liquid' },
76+
position: Position.create(0, 4),
77+
ch: '-',
78+
options,
79+
};
80+
81+
documentManager.open(params.textDocument.uri, '{% - %}', 1);
82+
const document = documentManager.get(params.textDocument.uri)?.textDocument;
83+
assert(document);
84+
85+
const result = await onTypeFormattingProvider.onTypeFormatting(params);
86+
assert(result);
87+
expect(TextDocument.applyEdits(document, result)).to.equal('{%- -%}');
88+
});
89+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import {
2+
Range,
3+
DocumentOnTypeFormattingParams,
4+
TextEdit,
5+
Position,
6+
} from 'vscode-languageserver-protocol';
7+
import { AugmentedSourceCode } from '../../documents';
8+
import { BaseOnTypeFormattingProvider } from '../types';
9+
10+
export class BracketsAutoclosingOnTypeFormattingProvider implements BaseOnTypeFormattingProvider {
11+
/**
12+
* This very complex piece of code here exists to provide a good autoclosing UX.
13+
*
14+
* The story is kind of long so here goes...
15+
*
16+
* What we want:
17+
* 1. Basic autoclosing of {{, {% with the corresponding pair (and spaces)
18+
* - user types: {{
19+
* - user sees: {{ | }} (with cursor position at |)
20+
* 2. Autoclosing of {{- with -}}, {%- with -%}
21+
* - user types: {{-
22+
* - user sees: {{- | -}} (with cursor at |)
23+
* 3. User adds whitespace stripping on one side of the braces of an existing tag
24+
* - user types: - at | in `{{| drop }}`
25+
* - user sees: {{- drop }}
26+
*
27+
* Why we can't do it with autoclosingPairs:
28+
* - VS Code's settings accepts autoclosingPairs and autocloseBefore
29+
* - autoclosingPairs is a set of pairs that should be autoclosed (e.g. ['{%', '%}'])
30+
* - autocloseBefore is a character set of 'allowed next characters' that would cause a closing pair
31+
* - If we put a space (' ') the autoclosingPairs set, then (3) from above becomes funky:
32+
* - assume autoclosingPairs = {|}, {{|}}, {{ | }}
33+
* - user types: a space at | in `{{| drop }}`
34+
* - user sees: {{ }}drop }}
35+
* - This happens because the space is an autocloseBefore character, it sees a space after the cursor
36+
* so it closes '{{ ' with ' }}' at the cursor position, resulting in '{{ }}drop }}'
37+
* - Something similar happens if we include the `-` in the autoclosing pairs
38+
* - This is annoying!
39+
*
40+
* So our solution is the following:
41+
* 1. We change the pairs to include the closing space (this way our cursor remains where we want it to be)
42+
* - {{| }}
43+
* - {%| %}
44+
* 2. We add this OnTypeFormattingProvider that does the following "fixes":
45+
* - {{| }} into {{ | }}
46+
* - {{ -| }} into {{- | -}}
47+
* - {%| %} into {% | %}
48+
* - {% -| %} into {%- | -%}
49+
*
50+
* This lets us avoid the unnecessary close and accomplish 1, 2 and 3 :)
51+
*
52+
* Fallback for editor.onTypeFormatting: false is to let the user type the `-` on both sides manually
53+
*/
54+
onTypeFormatting(
55+
document: AugmentedSourceCode,
56+
params: DocumentOnTypeFormattingParams,
57+
): TextEdit[] | null {
58+
const textDocument = document.textDocument;
59+
const ch = params.ch;
60+
// position is position of cursor so 1 ahead of char
61+
const { line, character } = params.position;
62+
// This is an early return to avoid doing currentLine.at(-1);
63+
if ((ch === ' ' && character <= 2) || character <= 1) return null;
64+
const currentLineRange = Range.create(Position.create(line, 0), Position.create(line + 1, 0));
65+
const currentLine = textDocument.getText(currentLineRange);
66+
const charIdx = ch === ' ' ? character - 2 : character - 1;
67+
const char = currentLine.at(charIdx);
68+
switch (char) {
69+
// here we fix {{| }} with {{ | }}
70+
// here we fix {%| %} with {% | %}
71+
case '{':
72+
case '%': {
73+
const chars = currentLine.slice(charIdx - 1, charIdx + 4);
74+
if (chars === '{{ }}' || chars === '{% %}') {
75+
return [TextEdit.insert(Position.create(line, charIdx + 1), ' ')];
76+
}
77+
}
78+
79+
// here we fix {{ -| }} to {{- | -}}
80+
// here we fix {% -| }} to {%- | -%}
81+
case '-': {
82+
// remember 0-index means 4th char
83+
if (charIdx < 3) return null;
84+
85+
const chars = currentLine.slice(charIdx - 3, charIdx + 4);
86+
if (chars === '{{ - }}' || chars === '{% - %}') {
87+
// Here we're being clever and doing the {{- -}} if the first character
88+
// you type is a `-`, leaving your cursor in the middle :)
89+
return [
90+
// Start with
91+
// {{ - }}
92+
// ^ start replace
93+
// ^ end replace (excluded)
94+
// Replace with '- ', get
95+
// {{- }}
96+
TextEdit.replace(
97+
Range.create(Position.create(line, charIdx - 1), Position.create(line, charIdx + 1)),
98+
'- ',
99+
),
100+
// Start with
101+
// {{ - }}
102+
// ^ char
103+
// ^ insertion point
104+
// Insert ' ' , get
105+
// {{ - -}}
106+
// Both together and you get {{- -}} with your cursor in the middle
107+
TextEdit.insert(Position.create(line, charIdx + 2), '-'),
108+
];
109+
}
110+
}
111+
}
112+
return null;
113+
}
114+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { afterEach, assert, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { DocumentOnTypeFormattingParams } from 'vscode-languageserver';
3+
import { Position } from 'vscode-languageserver-protocol';
4+
import { TextDocument } from 'vscode-languageserver-textdocument';
5+
import { DocumentManager } from '../../documents';
6+
import { OnTypeFormattingProvider } from '../OnTypeFormattingProvider';
7+
8+
const options: DocumentOnTypeFormattingParams['options'] = {
9+
insertSpaces: true,
10+
tabSize: 2,
11+
};
12+
13+
describe('Module: HtmlElementAutoclosingOnTypeFormattingProvider', () => {
14+
let documentManager: DocumentManager;
15+
let onTypeFormattingProvider: OnTypeFormattingProvider;
16+
let setCursorPositionSpy: ReturnType<typeof vi.fn>;
17+
const uri = 'file:///path/to/document.liquid';
18+
19+
beforeEach(() => {
20+
setCursorPositionSpy = vi.fn();
21+
documentManager = new DocumentManager();
22+
onTypeFormattingProvider = new OnTypeFormattingProvider(documentManager, setCursorPositionSpy);
23+
vi.useFakeTimers();
24+
});
25+
26+
afterEach(() => {
27+
vi.useRealTimers();
28+
});
29+
30+
it('should return a TextEdit to insert a closing <tag> when you type > for an unclosed tag', async () => {
31+
const source = '<div id="main">';
32+
documentManager.open(uri, source, 1);
33+
const document = documentManager.get(uri)?.textDocument!;
34+
35+
const params: DocumentOnTypeFormattingParams = {
36+
textDocument: { uri },
37+
position: document.positionAt(source.indexOf('>') + 1),
38+
ch: '>',
39+
options,
40+
};
41+
42+
assert(document);
43+
44+
const result = await onTypeFormattingProvider.onTypeFormatting(params);
45+
assert(result);
46+
expect(TextDocument.applyEdits(document, result)).to.equal('<div id="main"></div>');
47+
48+
vi.advanceTimersByTime(10);
49+
expect(setCursorPositionSpy).toHaveBeenCalledWith(
50+
document,
51+
document.positionAt(source.indexOf('>') + 1),
52+
);
53+
});
54+
55+
it('should return a TextEdit to insert a closing <tag> when you type > for an unclosed tag even if the unclosed tag is higher up', async () => {
56+
const CURSOR = '█';
57+
// in this scenario, the cursor is after div#inner, but the unclosed tag is div#main.
58+
// we still want to close it because that's probably what you want to do.
59+
const source = `
60+
<div id="main">
61+
<img>
62+
<div id="inner">█
63+
</div>
64+
`;
65+
documentManager.open(uri, source.replace(CURSOR, ''), 1);
66+
const document = documentManager.get(uri)?.textDocument!;
67+
68+
const indexOfCursor = source.indexOf(CURSOR);
69+
const params: DocumentOnTypeFormattingParams = {
70+
textDocument: { uri },
71+
position: document.positionAt(indexOfCursor),
72+
ch: '>',
73+
options,
74+
};
75+
76+
assert(document);
77+
78+
const result = await onTypeFormattingProvider.onTypeFormatting(params);
79+
assert(result);
80+
expect(TextDocument.applyEdits(document, result)).to.equal(`
81+
<div id="main">
82+
<img>
83+
<div id="inner"></div>
84+
</div>
85+
`);
86+
87+
vi.advanceTimersByTime(10);
88+
expect(setCursorPositionSpy).toHaveBeenCalledWith(document, document.positionAt(indexOfCursor));
89+
});
90+
91+
it('should return a TextEdit to insert a closing <tag> when you type > for an unclosed tag even if the unclosed tag is higher up and theres a sibling closed tag', async () => {
92+
const CURSOR = '█';
93+
// in this scenario, the cursor is after div#inner, but the unclosed tag is div#main.
94+
// we still want to close it because that's probably what you want to do.
95+
const source = `
96+
<div id="main">
97+
<div id="inner">█
98+
<div></div>
99+
</div>
100+
`;
101+
documentManager.open(uri, source.replace(CURSOR, ''), 1);
102+
const document = documentManager.get(uri)?.textDocument!;
103+
104+
const indexOfCursor = source.indexOf(CURSOR);
105+
const params: DocumentOnTypeFormattingParams = {
106+
textDocument: { uri },
107+
position: document.positionAt(indexOfCursor),
108+
ch: '>',
109+
options,
110+
};
111+
112+
assert(document);
113+
114+
const result = await onTypeFormattingProvider.onTypeFormatting(params);
115+
assert(result);
116+
expect(TextDocument.applyEdits(document, result)).to.equal(`
117+
<div id="main">
118+
<div id="inner"></div>
119+
<div></div>
120+
</div>
121+
`);
122+
123+
vi.advanceTimersByTime(10);
124+
expect(setCursorPositionSpy).toHaveBeenCalledWith(document, document.positionAt(indexOfCursor));
125+
});
126+
127+
it('should try to close dangling html open tags inside liquid branches', async () => {
128+
const CURSOR = '█';
129+
const scenarios = [
130+
'{% if cond %}<div>█{% endif %}',
131+
'{% if cond %}{% else %}<div>█{% endif %}',
132+
'{% unless cond %}<div>█{% endunless %}',
133+
'{% case thing %}{% when thing %}<div>█{% endif %}',
134+
];
135+
for (const source of scenarios) {
136+
documentManager.open(uri, source.replace(CURSOR, ''), 1);
137+
const document = documentManager.get(uri)?.textDocument!;
138+
const indexOfCursor = source.indexOf(CURSOR);
139+
const params: DocumentOnTypeFormattingParams = {
140+
textDocument: { uri },
141+
position: document.positionAt(indexOfCursor),
142+
ch: '>',
143+
options,
144+
};
145+
assert(document);
146+
const result = await onTypeFormattingProvider.onTypeFormatting(params);
147+
assert(result);
148+
expect(TextDocument.applyEdits(document, result)).to.equal(source.replace(CURSOR, '</div>'));
149+
150+
vi.advanceTimersByTime(10);
151+
expect(setCursorPositionSpy).toHaveBeenCalledWith(
152+
document,
153+
document.positionAt(indexOfCursor),
154+
);
155+
}
156+
});
157+
158+
it('should not try to close the tag when pressing > inside a Liquid tag if condition (what HTML would do)', async () => {
159+
const CURSOR = '█';
160+
const scenarios = ['<div {% if cond >█', '<div {% if cond >█ %}>', '<div {% if cond >█ 2 %}>'];
161+
for (const source of scenarios) {
162+
documentManager.open(uri, source.replace(CURSOR, ''), 1);
163+
const document = documentManager.get(uri)?.textDocument!;
164+
165+
const indexOfCursor = source.indexOf(CURSOR);
166+
const params: DocumentOnTypeFormattingParams = {
167+
textDocument: { uri },
168+
position: document.positionAt(indexOfCursor),
169+
ch: '>',
170+
options,
171+
};
172+
173+
assert(document);
174+
175+
const result = await onTypeFormattingProvider.onTypeFormatting(params);
176+
assert(!result);
177+
}
178+
});
179+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import {
2+
getName,
3+
HtmlElement,
4+
LiquidHTMLASTParsingError,
5+
LiquidHtmlNode,
6+
NodeTypes,
7+
toLiquidHtmlAST,
8+
} from '@shopify/liquid-html-parser';
9+
import { SourceCodeType } from '@shopify/theme-check-common';
10+
import {
11+
DocumentOnTypeFormattingParams,
12+
Position,
13+
Range,
14+
TextEdit,
15+
} from 'vscode-languageserver-protocol';
16+
import { TextDocument } from 'vscode-languageserver-textdocument';
17+
import { AugmentedSourceCode } from '../../documents';
18+
import { findCurrentNode } from '../../visitor';
19+
import { BaseOnTypeFormattingProvider, SetCursorPosition } from '../types';
20+
21+
const defer = (fn: () => void) => setTimeout(fn, 10);
22+
23+
/**
24+
* This class is responsible for closing dangling HTML elements.
25+
*
26+
* Say user types <script>, then we'd want `</script>` to be inserted.
27+
*
28+
* Thing is we want to do that only if the `</script>` isn't already present in the file.
29+
* If the user goes to edit `<script>` and types `>`, we don't want to insert `</script>` again.
30+
*
31+
* The "trick" we use here is to only add the `</script>` part if the
32+
* document.ast is an instance of LiquidHTMLASTParsingError and that the
33+
* unclosed element is of the correct name.
34+
*
35+
* @example:
36+
* ```html
37+
* <div id="main">
38+
* <div id="inner">|
39+
* </div>
40+
* ```
41+
* - The user just finished typing `<div id="inner">` inside the div#main.
42+
* - This parses as though the div#inner is closed and div#main isn't.
43+
* - That's OK.
44+
* - This makes a LiquidHTMLASTParsingError with unclosed div (the div#main).
45+
* - Since
46+
* - the cursor is at the end of a div, and
47+
* - the unclosed element is a div,
48+
* Then we can insert one automatically after the cursor and fix the AST.
49+
*
50+
* ```html
51+
* <div id="main">
52+
* <div id="inner">|</div>
53+
* </div>
54+
* ```
55+
*/
56+
export class HtmlElementAutoclosingOnTypeFormattingProvider
57+
implements BaseOnTypeFormattingProvider
58+
{
59+
constructor(private setCursorPosition: SetCursorPosition) {}
60+
61+
onTypeFormatting(
62+
document: AugmentedSourceCode<SourceCodeType.LiquidHtml>,
63+
params: DocumentOnTypeFormattingParams,
64+
): TextEdit[] | null {
65+
const textDocument = document.textDocument;
66+
const ch = params.ch;
67+
// position is position of cursor so 1 ahead of char
68+
const { line, character } = params.position;
69+
switch (ch) {
70+
// here we fix `>` with `</$unclosed>`
71+
case '>': {
72+
const ast = document.ast;
73+
if (
74+
ast instanceof LiquidHTMLASTParsingError &&
75+
ast.unclosed &&
76+
ast.unclosed.type === NodeTypes.HtmlElement &&
77+
(ast.unclosed.blockStartPosition.end === textDocument.offsetAt(params.position) ||
78+
shouldClose(ast.unclosed, nodeAtCursor(textDocument, params.position)))
79+
) {
80+
defer(() => this.setCursorPosition(textDocument, params.position));
81+
return [TextEdit.insert(Position.create(line, character), `</${ast.unclosed.name}>`)];
82+
} else if (!(ast instanceof Error)) {
83+
// Even though we accept dangling <div>s inside {% if condition %}, we prefer to auto-insert the </div>
84+
const [node] = findCurrentNode(ast, textDocument.offsetAt(params.position));
85+
if (isDanglingHtmlElement(node)) {
86+
defer(() => this.setCursorPosition(textDocument, params.position));
87+
return [TextEdit.insert(Position.create(line, character), `</${getName(node)}>`)];
88+
}
89+
}
90+
}
91+
}
92+
return null;
93+
}
94+
}
95+
96+
function nodeAtCursor(textDocument: TextDocument, position: Position) {
97+
const text = textDocument.getText(Range.create(Position.create(0, 0), position));
98+
try {
99+
const ast = toLiquidHtmlAST(text, {
100+
allowUnclosedDocumentNode: true,
101+
mode: 'tolerant',
102+
});
103+
104+
const [node, ancestors] = findCurrentNode(ast, textDocument.offsetAt(position));
105+
if (ancestors.at(-1)?.type === NodeTypes.HtmlElement) return ancestors.at(-1)!;
106+
if (node.type === NodeTypes.LiquidBranch) return ancestors.at(-1)!;
107+
return node;
108+
} catch {
109+
return null;
110+
}
111+
}
112+
113+
function shouldClose(unclosed: any, node: LiquidHtmlNode | null) {
114+
if (node === null || !('blockStartPosition' in node)) return false;
115+
116+
return (
117+
[NodeTypes.HtmlElement, NodeTypes.LiquidTag, NodeTypes.HtmlRawNode].includes(unclosed.type) &&
118+
getName(node) === unclosed.name
119+
);
120+
}
121+
122+
function isDanglingHtmlElement(node: LiquidHtmlNode): node is HtmlElement {
123+
return (
124+
node !== null &&
125+
node.type === NodeTypes.HtmlElement &&
126+
node.blockEndPosition.start === node.blockEndPosition.end
127+
);
128+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { DocumentOnTypeFormattingParams, Position, TextEdit } from 'vscode-languageserver-protocol';
2+
import { AugmentedSourceCode } from '../documents';
3+
import { TextDocument } from 'vscode-languageserver-textdocument';
4+
5+
export interface BaseOnTypeFormattingProvider {
6+
onTypeFormatting(
7+
document: AugmentedSourceCode,
8+
params: DocumentOnTypeFormattingParams,
9+
): TextEdit[] | null;
10+
}
11+
12+
export type SetCursorPosition = (textDocument: TextDocument, position: Position) => Promise<void>;

‎packages/theme-language-server-common/src/server/startServer.ts

+16-2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
Connection,
44
FileOperationRegistrationOptions,
55
InitializeResult,
6+
ShowDocumentRequest,
67
TextDocumentSyncKind,
78
} from 'vscode-languageserver';
89
import { URI } from 'vscode-uri';
@@ -66,7 +67,20 @@ export function startServer(
6667
const diagnosticsManager = new DiagnosticsManager(connection);
6768
const documentLinksProvider = new DocumentLinksProvider(documentManager);
6869
const codeActionsProvider = new CodeActionsProvider(documentManager, diagnosticsManager);
69-
const onTypeFormattingProvider = new OnTypeFormattingProvider(documentManager);
70+
const onTypeFormattingProvider = new OnTypeFormattingProvider(
71+
documentManager,
72+
async function setCursorPosition(textDocument, position) {
73+
if (!clientCapabilities.hasShowDocumentSupport) return;
74+
connection.sendRequest(ShowDocumentRequest.type, {
75+
uri: textDocument.uri,
76+
takeFocus: true,
77+
selection: {
78+
start: position,
79+
end: position,
80+
},
81+
});
82+
},
83+
);
7084
const linkedEditingRangesProvider = new LinkedEditingRangesProvider(documentManager);
7185
const documentHighlightProvider = new DocumentHighlightsProvider(documentManager);
7286
const renameProvider = new RenameProvider(documentManager);
@@ -204,7 +218,7 @@ export function startServer(
204218
},
205219
documentOnTypeFormattingProvider: {
206220
firstTriggerCharacter: ' ',
207-
moreTriggerCharacter: ['{', '%', '-'],
221+
moreTriggerCharacter: ['{', '%', '-', '>'],
208222
},
209223
documentLinkProvider: {
210224
resolveProvider: false,

‎packages/theme-language-server-common/src/visitor.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,10 @@ export function findCurrentNode(
9393
current,
9494
ancestors.concat(current),
9595
(child, lineage) => {
96-
if (isCovered(child, cursorPosition) && size(child) <= size(current)) {
96+
if (
97+
isUnclosed(child) ||
98+
(isCovered(child, cursorPosition) && size(child) <= size(current))
99+
) {
97100
current = child;
98101
ancestors = lineage;
99102
}
@@ -111,3 +114,12 @@ function isCovered(node: LiquidHtmlNode, offset: number): boolean {
111114
function size(node: LiquidHtmlNode): number {
112115
return node.position.end - node.position.start;
113116
}
117+
118+
function isUnclosed(node: LiquidHtmlNode): boolean {
119+
if ('blockEndPosition' in node) {
120+
return node.blockEndPosition?.end === -1;
121+
} else if ('children' in node) {
122+
return node.children!.length > 0;
123+
}
124+
return false;
125+
}
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { EnterAction, OnEnterRule } from 'vscode';
2+
import { voidElements } from './constants';
23

3-
export interface OnEnterRuleJSON extends Omit<OnEnterRule, 'action'> {
4+
export interface OnEnterRuleJSON extends Omit<OnEnterRule, 'action' | 'beforeText' | 'afterText'> {
5+
beforeText?: string;
6+
afterText?: string;
47
action: EnterActionJSON;
58
}
69

@@ -9,5 +12,19 @@ export interface EnterActionJSON extends Omit<EnterAction, 'indentAction'> {
912
}
1013

1114
export async function onEnterRules(): Promise<OnEnterRuleJSON[]> {
12-
return [];
15+
// Adapted from the Monaco Editor source code
16+
// https://github.com/microsoft/monaco-editor/blob/f6dc0eb8fce67e57f6036f4769d92c1666cdf546/src/basic-languages/html/html.ts#L88
17+
return [
18+
{
19+
beforeText: `<(?!(?:${voidElements.join('|')}))([_:\\w][_:\\w-.\\d]*)([^/>]*(?!/)>)[^<]*$`,
20+
afterText: `^<\\/([_:\\w][_:\\w-.\\d]*)\\s*>$`,
21+
action: {
22+
indent: 'indentOutdent',
23+
},
24+
},
25+
{
26+
beforeText: `<(?!(?:${voidElements.join('|')}))(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$`,
27+
action: { indent: 'indent' },
28+
},
29+
];
1330
}

0 commit comments

Comments
 (0)
Please sign in to comment.