Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/preset block settings completion #732

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/beige-ties-appear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/theme-check-common': patch
---

[internal] Add `strict` arg for `parseJSON`
10 changes: 10 additions & 0 deletions .changeset/selfish-ants-stare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@shopify/theme-language-server-common': minor
'@shopify/theme-language-server-node': minor
---

Support completion + hover for presets blocks settings under `{% schema %}` tag

- Hover + Completion description for `presets.[].blocks.[].settings` will be from the referenced
block's setting's label - i.e. `settings.[].label`
- The label will be translated if it contains a translation key
9 changes: 7 additions & 2 deletions packages/theme-check-common/src/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ const PARSE_OPTS = {

export function parseJSON(source: string): any | Error;
export function parseJSON(source: string, defaultValue: any): any;
export function parseJSON(source: string, defaultValue?: any): any | Error {
export function parseJSON(source: string, defaultValue: any, isStrict: boolean): any;
export function parseJSON(
source: string,
defaultValue?: any,
isStrict: boolean = true,
): any | Error {
try {
/**
* The jsonc-parser is fault-tolerant and typically returns a valid
Expand All @@ -20,7 +25,7 @@ export function parseJSON(source: string, defaultValue?: any): any | Error {
const errors: ParseError[] = [];
const result = parse(source, errors, PARSE_OPTS);

if (errors.length) {
if (errors.length && isStrict) {
throw errors[0];
}

Expand Down
20 changes: 12 additions & 8 deletions packages/theme-check-common/src/to-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,16 @@ export async function toSchema(
uri: UriString,
sourceCode: SourceCode,
isValidSchema: IsValidSchema | undefined,
isStrict: boolean = true,
): Promise<AppBlockSchema | SectionSchema | ThemeBlockSchema | undefined> {
if (sourceCode.type !== SourceCodeType.LiquidHtml) return undefined;
switch (true) {
case mode === 'app' && isBlock(uri):
return toAppBlockSchema(uri, sourceCode.ast);
return toAppBlockSchema(uri, sourceCode.ast, isStrict);
case mode === 'theme' && isBlock(uri):
return toBlockSchema(uri, sourceCode.ast, isValidSchema);
return toBlockSchema(uri, sourceCode.ast, isValidSchema, isStrict);
case mode === 'theme' && isSection(uri):
return toSectionSchema(uri, sourceCode.ast, isValidSchema);
return toSectionSchema(uri, sourceCode.ast, isValidSchema, isStrict);
default:
return undefined;
}
Expand Down Expand Up @@ -76,10 +77,11 @@ export async function toBlockSchema(
uri: UriString,
liquidAst: LiquidHtmlNode | Error,
isValidSchema: IsValidSchema | undefined,
isStrict: boolean,
): Promise<ThemeBlockSchema> {
const name = path.basename(uri, '.liquid');
const schemaNode = toSchemaNode(liquidAst);
const parsed = toParsed(schemaNode);
const parsed = toParsed(schemaNode, isStrict);
const ast = toAst(schemaNode);

return {
Expand All @@ -100,10 +102,11 @@ export async function toSectionSchema(
uri: UriString,
liquidAst: LiquidHtmlNode | Error,
isValidSchema: IsValidSchema | undefined,
isStrict: boolean,
): Promise<SectionSchema> {
const name = path.basename(uri, '.liquid');
const schemaNode = toSchemaNode(liquidAst);
const parsed = toParsed(schemaNode);
const parsed = toParsed(schemaNode, isStrict);
const ast = toAst(schemaNode);

return {
Expand All @@ -121,10 +124,11 @@ export async function toSectionSchema(
export async function toAppBlockSchema(
uri: UriString,
liquidAst: LiquidHtmlNode | Error,
isStrict: boolean,
): Promise<AppBlockSchema> {
const name = path.basename(uri, '.liquid');
const schemaNode = toSchemaNode(liquidAst);
const parsed = toParsed(schemaNode);
const parsed = toParsed(schemaNode, isStrict);
const ast = toAst(schemaNode);

return {
Expand Down Expand Up @@ -178,9 +182,9 @@ export async function getSchemaFromJSON(context: Context<SourceCodeType.JSON, Sc
};
}

function toParsed(schemaNode: LiquidRawTag | Error): any | Error {
function toParsed(schemaNode: LiquidRawTag | Error, isStrict: boolean): any | Error {
if (schemaNode instanceof Error) return schemaNode;
return parseJSON(schemaNode.body.value);
return parseJSON(schemaNode.body.value, undefined, isStrict);
}

function toAst(schemaNode: LiquidRawTag | Error): SourceCode<SourceCodeType.JSON>['ast'] | Error {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export class DocumentManager {
if (!this.getModeForUri || !this.isValidSchema) return undefined;

const mode = await this.getModeForUri!(uri);
return toSchema(mode, uri, sourceCode, this.isValidSchema);
return toSchema(mode, uri, sourceCode, this.isValidSchema, false);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great question! This is definitely going to be a controversial take here. Would love to get @charlespwd 's opinion on it before i merge.

  • Our default schema parser is very strict (does not like any syntax errors)
  • But if you are a dev who's typing their schema, you're going to have syntax errors while typing.
    • E.g. You haven't provided a value for the key you are about to type
       "presets": [
         {
             "settings": {
                 "█"
             }
         }
       ]
    
    • You need auto-completion to type inside that quotes, but since your schema is invalid, all auto-completion gets disabled (BAD)
    • We need less strict JSON parsing (at least during completion, hovers, etc.) BUT not during theme checks

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah! That makes sense to me I think. Not sure what else we could do otherwise, tbh.

}),
};
default:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import { RequestContext } from './RequestContext';
import { findSchemaNode } from './utils';
import { SettingsPropertyCompletionProvider } from './completions/providers/SettingsPropertyCompletionProvider';
import { SettingsHoverProvider } from './hover/providers/SettingsHoverProvider';
import { PresetsBlockSettingsPropertyCompletionProvider } from './completions/providers/PresetsBlockSettingsPropertyCompletionProvider';
import { PresetsBlockSettingsHoverProvider } from './hover/providers/PresetsBlockSettingsHoverProvider';

/** The getInfoContribution API will only fallback if we return undefined synchronously */
const SKIP_CONTRIBUTION = undefined as any;
Expand Down Expand Up @@ -55,11 +57,16 @@ export class JSONContributions implements JSONWorkerContribution {
new TranslationPathHoverProvider(),
new SchemaTranslationHoverProvider(getDefaultSchemaTranslations),
new SettingsHoverProvider(getDefaultSchemaTranslations),
new PresetsBlockSettingsHoverProvider(getDefaultSchemaTranslations, getThemeBlockSchema),
];
this.completionProviders = [
new SchemaTranslationsCompletionProvider(getDefaultSchemaTranslations),
new BlockTypeCompletionProvider(getThemeBlockNames),
new PresetsBlockTypeCompletionProvider(getThemeBlockNames, getThemeBlockSchema),
new PresetsBlockSettingsPropertyCompletionProvider(
getDefaultSchemaTranslations,
getThemeBlockSchema,
),
new SettingsPropertyCompletionProvider(getDefaultSchemaTranslations),
];
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { JSONPath } from 'vscode-json-languageservice';
import { GetThemeBlockSchema } from '../../JSONContributions';
import { RequestContext } from '../../RequestContext';
import { JSONCompletionItem, JSONCompletionProvider } from '../JSONCompletionProvider';
import { isSectionOrBlockFile } from '../../utils';
import { deepGet, isError, SourceCodeType } from '@shopify/theme-check-common';
import { isSectionOrBlockSchema } from './BlockTypeCompletionProvider';
import { GetTranslationsForURI } from '../../../translations';
import { schemaSettingsPropertyCompletionItems } from './helpers/schemaSettings';

/**
* The PresetsBlockSettingsPropertyCompletionProvider offers value completions of the
* `presets.[].(recursive blocks.[]).settings` keys inside section and theme block `{% schema %}` tags.
*
* @example
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love including the description and example here. I will add this to the branch I'm working on!

* {% schema %}
* {
* "presets": [
* {
* "blocks": [
* {
* "type": "block-type",
* "settings": {
* "█"
* }
* },
* ]
* },
* ]
* }
* {% endschema %}
*/
export class PresetsBlockSettingsPropertyCompletionProvider implements JSONCompletionProvider {
constructor(
private getDefaultSchemaTranslations: GetTranslationsForURI,
private getThemeBlockSchema: GetThemeBlockSchema,
) {}

async completeProperty(context: RequestContext, path: JSONPath): Promise<JSONCompletionItem[]> {
const { doc } = context;

if (doc.type !== SourceCodeType.LiquidHtml) return [];
if (!isSectionOrBlockFile(doc.uri) || !isPresetsBlocksSettingsPath(path)) {
return [];
}

const schema = await doc.getSchema();

if (!schema || !isSectionOrBlockSchema(schema) || isError(schema.parsed)) {
return [];
}

const blockType = deepGet(schema.parsed, [...path.slice(0, -1), 'type']);

if (!blockType) {
return [];
}

const blockOriginSchema = await this.getThemeBlockSchema(doc.uri, blockType);

if (
!blockOriginSchema ||
isError(blockOriginSchema.parsed) ||
!isSectionOrBlockSchema(blockOriginSchema)
) {
return [];
}

if (!blockOriginSchema.parsed?.settings || !Array.isArray(blockOriginSchema.parsed?.settings)) {
return [];
}

const translations = await this.getDefaultSchemaTranslations(doc.textDocument.uri);

return schemaSettingsPropertyCompletionItems(blockOriginSchema.parsed, translations);
}
}

// `blocks` can be nested within other `blocks`
// We need to ensure the last leg of the path is { "blocks": [{ "settings": { "█" } }] }
function isPresetsBlocksSettingsPath(path: JSONPath) {
return path.at(0) === 'presets' && path.at(-3) === 'blocks' && path.at(-1) === 'settings';
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
isCompletionList,
mockJSONLanguageService,
} from '../../test/test-helpers';
import { SourceCodeType, ThemeBlock } from '@shopify/theme-check-common';

describe('Unit: PresetsBlockTypeCompletionProvider', () => {
const rootUri = 'file:///root/';
Expand Down Expand Up @@ -130,63 +129,4 @@ describe('Unit: PresetsBlockTypeCompletionProvider', () => {
});
}
});

describe('invalid schema', () => {
it('does not complete when schema is invalid', async () => {
const source = `
{% schema %}
typo
{
"blocks": [{"type": "@theme"}],
"presets": [{
"blocks": [
{ "type": "█" },
]
}]
}
{% endschema %}
`;

const params = getRequestParams(documentManager, 'sections/section.liquid', source);
const completions = await jsonLanguageService.completions(params);

assert(isCompletionList(completions));
expect(completions.items).to.have.lengthOf(0);
});

it('does not complete when parent block schema is invalid', async () => {
const source = `
{% schema %}
{
"blocks": [{"type": "custom-block"}],
"presets": [{
"blocks": [
{
"type": "custom-block",
"blocks": [{"type": "█"}],
},
]
}]
}
{% endschema %}
`;

documentManager.open(
`${rootUri}/blocks/custom-block.liquid`,
`{% schema %}
typo
{
"blocks": [{"type": "@theme"}]
}
{% endschema %}`,
1,
);

const params = getRequestParams(documentManager, 'sections/section.liquid', source);
const completions = await jsonLanguageService.completions(params);

assert(isCompletionList(completions));
expect(completions.items).to.have.lengthOf(0);
});
});
});
Loading
Loading