Skip to content

Commit

Permalink
Add better auto-closing UX for Liquid pairs
Browse files Browse the repository at this point in the history
  • Loading branch information
charlespwd committed Nov 9, 2023
1 parent 9c32ec9 commit baa6d37
Show file tree
Hide file tree
Showing 10 changed files with 299 additions and 18 deletions.
6 changes: 6 additions & 0 deletions .changeset/smooth-bottles-melt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/theme-language-server-common': minor
'theme-check-vscode': minor
---

Add better auto-closing UX for Liquid pairs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, beforeEach, it, expect, assert } from 'vitest';
import { OnTypeFormattingProvider } from './OnTypeFormattingProvider';
import { DocumentManager } from '../documents';
import { DocumentOnTypeFormattingParams } from 'vscode-languageserver';
import { Position, TextEdit, Range } from 'vscode-languageserver-protocol';
import { TextDocument } from 'vscode-languageserver-textdocument';

const options: DocumentOnTypeFormattingParams['options'] = {
insertSpaces: true,
tabSize: 2,
};

describe('Module: OnTypeFormattingProvider', () => {
let documentManager: DocumentManager;
let onTypeFormattingProvider: OnTypeFormattingProvider;

beforeEach(() => {
documentManager = new DocumentManager();
onTypeFormattingProvider = new OnTypeFormattingProvider(documentManager);
});

it('should return null for non-existent documents', async () => {
const params: DocumentOnTypeFormattingParams = {
textDocument: { uri: 'file:///path/to/non-existent-document.liquid' },
position: Position.create(0, 0),
ch: ' ',
options,
};

const result = await onTypeFormattingProvider.onTypeFormatting(params);
expect(result).toBeNull();
});

it('should return null if character is space and cursor position is less than or equal to 2', async () => {
const params: DocumentOnTypeFormattingParams = {
textDocument: { uri: 'file:///path/to/document.liquid' },
position: Position.create(0, 1),
ch: ' ',
options,
};

documentManager.open(params.textDocument.uri, 'Sample text content', 1);

const result = await onTypeFormattingProvider.onTypeFormatting(params);
expect(result).toBeNull();
});

it('should return null if character is not space and cursor position is less than or equal to 1', async () => {
const params: DocumentOnTypeFormattingParams = {
textDocument: { uri: 'file:///path/to/document.liquid' },
position: Position.create(0, 0),
ch: 'a',
options,
};

documentManager.open(params.textDocument.uri, 'Sample text content', 1);

const result = await onTypeFormattingProvider.onTypeFormatting(params);
expect(result).toBeNull();
});

it('should return a TextEdit to insert a space after "{{" in "{{ }}"', async () => {
const params: DocumentOnTypeFormattingParams = {
textDocument: { uri: 'file:///path/to/document.liquid' },
position: Position.create(0, 2),
ch: '{',
options,
};

documentManager.open(params.textDocument.uri, '{{ }}', 1);
const document = documentManager.get(params.textDocument.uri)?.textDocument;
assert(document);

const result = await onTypeFormattingProvider.onTypeFormatting(params);
assert(result);
expect(TextDocument.applyEdits(document, result)).to.equal('{{ }}');
});

it('should return a TextEdit to insert a space after "{%" in "{% %}"', async () => {
const params: DocumentOnTypeFormattingParams = {
textDocument: { uri: 'file:///path/to/document.liquid' },
position: Position.create(0, 2),
ch: '%',
options,
};

documentManager.open(params.textDocument.uri, '{% %}', 1);
const document = documentManager.get(params.textDocument.uri)?.textDocument;
assert(document);

const result = await onTypeFormattingProvider.onTypeFormatting(params);
assert(result);
expect(TextDocument.applyEdits(document, result)).to.equal('{% %}');
});

it('should return a TextEdit to replace and insert characters in "{{ - }}"', async () => {
const params: DocumentOnTypeFormattingParams = {
textDocument: { uri: 'file:///path/to/document.liquid' },
position: Position.create(0, 4),
ch: '-',
options,
};

documentManager.open(params.textDocument.uri, '{{ - }}', 1);
const document = documentManager.get(params.textDocument.uri)?.textDocument;
assert(document);

const result = await onTypeFormattingProvider.onTypeFormatting(params);
assert(result);
expect(TextDocument.applyEdits(document, result)).to.equal('{{- -}}');
});

it('should return a TextEdit to replace and insert characters in "{% - %}"', async () => {
const params: DocumentOnTypeFormattingParams = {
textDocument: { uri: 'file:///path/to/document.liquid' },
position: Position.create(0, 4),
ch: '-',
options,
};

documentManager.open(params.textDocument.uri, '{% - %}', 1);
const document = documentManager.get(params.textDocument.uri)?.textDocument;
assert(document);

const result = await onTypeFormattingProvider.onTypeFormatting(params);
assert(result);
expect(TextDocument.applyEdits(document, result)).to.equal('{%- -%}');
});

it('should return null for characters not matching any case', async () => {
const params: DocumentOnTypeFormattingParams = {
textDocument: { uri: 'file:///path/to/document.liquid' },
position: Position.create(0, 3),
ch: 'a',
options,
};

documentManager.open(params.textDocument.uri, 'Sample text content', 1);

const result = await onTypeFormattingProvider.onTypeFormatting(params);
expect(result).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { DocumentManager } from '../documents';
import { DocumentOnTypeFormattingParams } from 'vscode-languageserver';
import { Range, Position, TextEdit } from 'vscode-languageserver-protocol';

export class OnTypeFormattingProvider {
constructor(public documentManager: DocumentManager) {}

/**
* This very complex piece of code here exists to provide a good autoclosing UX.
*
* The story is kind of long so here goes...
*
* What we want:
* 1. Basic autoclosing of {{, {% with the corresponding pair (and spaces)
* - user types: {{
* - user sees: {{ | }} (with cursor position at |)
* 2. Autoclosing of {{- with -}}, {%- with -%}
* - user types: {{-
* - user sees: {{- | -}} (with cursor at |)
* 3. User adds whitespace stripping on one side of the braces of an existing tag
* - user types: - at | in `{{| drop }}`
* - user sees: {{- drop }}
*
* Why we can't do it with autoclosingPairs:
* - VS Code's settings accepts autoclosingPairs and autocloseBefore
* - autoclosingPairs is a set of pairs that should be autoclosed (e.g. ['{%', '%}'])
* - autocloseBefore is a character set of 'allowed next characters' that would cause a closing pair
* - If we put a space (' ') the autoclosingPairs set, then (3) from above becomes funky:
* - assume autoclosingPairs = {|}, {{|}}, {{ | }}
* - user types: a space at | in `{{| drop }}`
* - user sees: {{ }}drop }}
* - This happens because the space is an autocloseBefore character, it sees a space after the cursor
* so it closes '{{ ' with ' }}' at the cursor position, resulting in '{{ }}drop }}'
* - Something similar happens if we include the `-` in the autoclosing pairs
* - This is annoying!
*
* So our solution is the following:
* 1. We change the pairs to include the closing space (this way our cursor remains where we want it to be)
* - {{| }}
* - {%| %}
* 2. We add this OnTypeFormattingProvider that does the following "fixes":
* - {{| }} into {{ | }}
* - {{ -| }} into {{- | -}}
* - {%| %} into {% | %}
* - {% -| %} into {%- | -%}
*
* This lets us avoid the unnecessary close and accomplish 1, 2 and 3 :)
*
* Fallback for editor.onTypeFormatting: false is to let the user type the `-` on both sides manually
*/
async onTypeFormatting(params: DocumentOnTypeFormattingParams) {
const document = this.documentManager.get(params.textDocument.uri);
if (!document) return null;
const textDocument = document.textDocument;
const ch = params.ch;
// position is position of cursor so 1 ahead of char
const { line, character } = params.position;
// This is an early return to avoid doing currentLine.at(-1);
if ((ch === ' ' && character <= 2) || character <= 1) return null;
const currentLineRange = Range.create(Position.create(line, 0), Position.create(line + 1, 0));
const currentLine = textDocument.getText(currentLineRange);
const charIdx = ch === ' ' ? character - 2 : character - 1;
const char = currentLine.at(charIdx);
switch (char) {
// here we fix {{| }} with {{ | }}
case '{': {
const chars = currentLine.slice(charIdx - 1, charIdx + 4);
if (chars === '{{ }}') {
return [TextEdit.insert(Position.create(line, charIdx + 1), ' ')];
}
}

// here we fix {%| %} with {% | %}
case '%': {
const chars = currentLine.slice(charIdx - 1, charIdx + 4);
if (chars === '{% %}') {
return [TextEdit.insert(Position.create(line, charIdx + 1), ' ')];
}
}

// here we fix {{ -| }} to {{- | -}}
// here we fix {% -| }} to {%- | -%}
case '-': {
// remember 0-index means 4th char
if (charIdx < 3) return null;

const chars = currentLine.slice(charIdx - 3, charIdx + 4);
if (chars === '{{ - }}' || chars === '{% - %}') {
// Here we're being clever and doing the {{- -}} if the first character
// you type is a `-`, leaving your cursor in the middle :)
return [
// Start with
// {{ - }}
// ^ start replace
// ^ end replace (excluded)
// Replace with '- ', get
// {{- }}
TextEdit.replace(
Range.create(Position.create(line, charIdx - 1), Position.create(line, charIdx + 1)),
'- ',
),
// Start with
// {{ - }}
// ^ char
// ^ insertion point
// Insert ' ' , get
// {{ - -}}
// Both together and you get {{- -}} with your cursor in the middle
TextEdit.insert(Position.create(line, charIdx + 2), '-'),
];
}
}
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { OnTypeFormattingProvider } from './OnTypeFormattingProvider';
30 changes: 20 additions & 10 deletions packages/theme-language-server-common/src/server/startServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,21 @@ import {
InitializeResult,
TextDocumentSyncKind,
} from 'vscode-languageserver';
import { debounce } from '../utils';
import { DiagnosticsManager, makeRunChecks } from '../diagnostics';
import { DocumentManager } from '../documents';
import { Dependencies } from '../types';
import { VERSION } from '../version';
import { DocumentLinksProvider } from '../documentLinks';
import { URI } from 'vscode-uri';
import { ClientCapabilities } from '../ClientCapabilities';
import { CodeActionKinds, CodeActionsProvider } from '../codeActions';
import { Commands, ExecuteCommandProvider } from '../commands';
import { CompletionsProvider } from '../completions';
import { GetSnippetNamesForURI } from '../completions/providers/RenderSnippetCompletionProvider';
import { DiagnosticsManager, makeRunChecks } from '../diagnostics';
import { DocumentLinksProvider } from '../documentLinks';
import { DocumentManager } from '../documents';
import { OnTypeFormattingProvider } from '../formatting';
import { HoverProvider } from '../hover';
import { Commands, ExecuteCommandProvider } from '../commands';
import { ClientCapabilities } from '../ClientCapabilities';
import { GetTranslationsForURI, useBufferOrInjectedTranslations } from '../translations';
import { GetSnippetNamesForURI } from '../completions/providers/RenderSnippetCompletionProvider';
import { URI } from 'vscode-uri';
import { Dependencies } from '../types';
import { debounce } from '../utils';
import { VERSION } from '../version';

const defaultLogger = () => {};

Expand Down Expand Up @@ -56,6 +57,7 @@ export function startServer(
const diagnosticsManager = new DiagnosticsManager(connection);
const documentLinksProvider = new DocumentLinksProvider(documentManager);
const codeActionsProvider = new CodeActionsProvider(documentManager, diagnosticsManager);
const onTypeFormattingProvider = new OnTypeFormattingProvider(documentManager);

const findThemeRootURI = async (uri: string) => {
const rootUri = await findConfigurationRootURI(uri);
Expand Down Expand Up @@ -156,6 +158,10 @@ export function startServer(
completionProvider: {
triggerCharacters: ['.', '{{ ', '{% ', '<', '/', '[', '"', "'"],
},
documentOnTypeFormattingProvider: {
firstTriggerCharacter: ' ',
moreTriggerCharacter: ['{', '%', '-'],
},
documentLinkProvider: {
resolveProvider: false,
workDoneProgress: false,
Expand Down Expand Up @@ -227,6 +233,10 @@ export function startServer(
return hoverProvider.hover(params);
});

connection.onDocumentOnTypeFormatting(async (params) => {
return onTypeFormattingProvider.onTypeFormatting(params);
});

// These notifications could cause a MissingSnippet check to be invalidated
//
// It is not guaranteed that the file is or was opened when it was
Expand Down
5 changes: 5 additions & 0 deletions packages/vscode-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@
}
}
},
"configurationDefaults": {
"[liquid]": {
"editor.formatOnType": true
}
},
"languages": [
{
"id": "liquid",
Expand Down
6 changes: 3 additions & 3 deletions packages/vscode-extension/scripts/indentation-rules.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { describe, it, expect } from 'vitest';
import { voidElements, openingLiquidTags } from './constants';
import indentationRules from './indentation-rules';
import { increaseIndentPattern, decreaseIndentPattern } from './indentation-rules';

describe('Module: indentationRules', () => {
const increase = new RegExp(indentationRules.increaseIndentPattern, 'im');
const decrease = new RegExp(indentationRules.decreaseIndentPattern, 'im');
const increase = new RegExp(increaseIndentPattern(), 'im');
const decrease = new RegExp(decreaseIndentPattern(), 'im');

it('should match non-void elements', () => {
expect('<html>').to.match(increase);
Expand Down
5 changes: 2 additions & 3 deletions packages/vscode-extension/scripts/indentation-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface IndentationRulesJSON {
}

// https://regex101.com/r/G4OYnb/1
function increaseIndentPattern() {
export function increaseIndentPattern() {
const patterns = [
// Opening HTML tags that are not self closing. Here we use a negative
// lookahead (?!) to make sure that the next character after < is not /
Expand Down Expand Up @@ -44,8 +44,7 @@ function increaseIndentPattern() {
return String.raw`(${patterns.join('|')})$`;
}

//
function decreaseIndentPattern() {
export function decreaseIndentPattern() {
const patterns = [
// Closing HTML tags
String.raw`<\/[^>]+>`,
Expand Down
4 changes: 2 additions & 2 deletions packages/vscode-extension/scripts/language-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ export async function makeConfig(): Promise<any> {
],
autoClosingPairs: [
['{', '}'],
['{{', '}}'],
['{%', '%}'],
['{{', ' }}'],
['{%', ' %}'],
['[', ']'],
['(', ')'],
['"', '"'],
Expand Down
1 change: 1 addition & 0 deletions packages/vscode-extension/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"rootDir": "src",
"strict": true,
"resolveJsonModule": true,
"declarationDir": "/tmp/vscode-extension-useless-declarations",
"composite": true,
"noImplicitAny": false,
"paths": {
Expand Down

0 comments on commit baa6d37

Please sign in to comment.