Skip to content

Commit

Permalink
Improve the JSON completion & hover building blocks (#622)
Browse files Browse the repository at this point in the history
The json-languageservice API was a bit awkward to use. Had a bunch of
weird type problems that nobody should really have to care about.

In this PR, I use an adapter to make folks write Completion & Hover
providers in a manner similar to what we do with liquid files.

It's not 100% the same, but it's similar enough and abstracts the weird
quirks like "return undefined not Promise<undefined> when you can't
hover something".
  • Loading branch information
charlespwd authored Nov 29, 2024
1 parent 611fc3b commit 05b928e
Show file tree
Hide file tree
Showing 15 changed files with 385 additions and 284 deletions.
6 changes: 6 additions & 0 deletions .changeset/nice-candles-sparkle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/theme-language-server-common': patch
'@shopify/theme-check-common': patch
---

[internal] Rejig how we do JSON completion & Hover
1 change: 1 addition & 0 deletions packages/theme-check-common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export * from './utils/error';
export * from './utils/indexBy';
export * from './utils/memo';
export * from './utils/types';
export * from './utils/object';
export * from './visitor';

const defaultErrorHandler = (_error: Error): void => {
Expand Down
2 changes: 1 addition & 1 deletion packages/theme-check-common/src/utils/object.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export function deepGet(obj: any, path: string[]): any {
export function deepGet(obj: any, path: (string | number)[]): any {
return path.reduce((acc, key) => acc?.[key], obj);
}
120 changes: 120 additions & 0 deletions packages/theme-language-server-common/src/json/JSONContributions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { parseJSON, SourceCodeType } from '@shopify/theme-check-common';
import {
CompletionsCollector,
JSONPath,
JSONWorkerContribution,
MarkedString,
} from 'vscode-json-languageservice';
import { AugmentedSourceCode, DocumentManager } from '../documents';
import { GetTranslationsForURI } from '../translations';
import { JSONCompletionProvider } from './completions/JSONCompletionProvider';
import { SchemaTranslationsCompletionProvider } from './completions/providers/SchemaTranslationCompletionProvider';
import { JSONHoverProvider } from './hover/JSONHoverProvider';
import { SchemaTranslationHoverProvider } from './hover/providers/SchemaTranslationHoverProvider';
import { TranslationPathHoverProvider } from './hover/providers/TranslationPathHoverProvider';
import { RequestContext } from './RequestContext';
import { findSchemaNode } from './utils';

/** The getInfoContribution API will only fallback if we return undefined synchronously */
const SKIP_CONTRIBUTION = undefined as any;

/**
* I'm not a fan of how json-languageservice does its feature contributions. It's too different
* from everything else we do in here.
*
* Instead, we'll have this little adapter that makes the completions and hover providers feel
* a bit more familiar.
*/
export class JSONContributions implements JSONWorkerContribution {
private hoverProviders: JSONHoverProvider[];
private completionProviders: JSONCompletionProvider[];

constructor(
private documentManager: DocumentManager,
getDefaultSchemaTranslations: GetTranslationsForURI,
) {
this.hoverProviders = [
new TranslationPathHoverProvider(),
new SchemaTranslationHoverProvider(getDefaultSchemaTranslations),
];
this.completionProviders = [
new SchemaTranslationsCompletionProvider(getDefaultSchemaTranslations),
];
}

getInfoContribution(uri: string, location: JSONPath): Promise<MarkedString[]> {
const doc = this.documentManager.get(uri);
if (!doc) return SKIP_CONTRIBUTION;
const context = this.getContext(doc);
const provider = this.hoverProviders.find((p) => p.canHover(context, location));
if (!provider) return SKIP_CONTRIBUTION;
return provider.hover(context, location);
}

async collectPropertyCompletions(
uri: string,
location: JSONPath,
// Don't know what those three are for.
_currentWord: string,
_addValue: boolean,
_isLast: boolean,
result: CompletionsCollector,
): Promise<void> {
const doc = this.documentManager.get(uri);
if (!doc || doc.ast instanceof Error) return;

const items = await Promise.all(
this.completionProviders
.filter((provider) => provider.completeProperty)
.map((provider) => provider.completeProperty!(this.getContext(doc), location)),
);

for (const item of items.flat()) {
result.add(item);
}
}

async collectValueCompletions(
uri: string,
location: JSONPath,
propertyKey: string,
result: CompletionsCollector,
): Promise<void> {
const doc = this.documentManager.get(uri);
if (!doc || doc.ast instanceof Error) return;

const items = await Promise.all(
this.completionProviders
.filter((provider) => provider.completeValue)
.map((provider) =>
provider.completeValue!(this.getContext(doc), location.concat(propertyKey)),
),
);

for (const item of items.flat()) {
result.add(item);
}
}

/** I'm not sure we want to do anything with that... but TS requires us to have it */
async collectDefaultCompletions(_uri: string, _result: CompletionsCollector): Promise<void> {}

private getContext(doc: AugmentedSourceCode): RequestContext {
const context: RequestContext = {
doc,
};

if (doc.type === SourceCodeType.LiquidHtml && !(doc.ast instanceof Error)) {
const schema = findSchemaNode(doc.ast);
if (!schema) return SKIP_CONTRIBUTION;
const jsonString = schema?.source.slice(
schema.blockStartPosition.end,
schema.blockEndPosition.start,
);
context.schema = schema;
context.parsed = parseJSON(jsonString);
}

return context;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ import {
import { TextDocument } from 'vscode-languageserver-textdocument';
import { DocumentManager } from '../documents';
import { GetTranslationsForURI } from '../translations';
import { SchemaTranslationContributions } from './SchemaTranslationContributions';
import { TranslationFileContributions } from './TranslationFileContributions';
import { JSONContributions } from './JSONContributions';

export class JSONLanguageService {
// We index by Mode here because I don't want to reconfigure the service depending on the URI.
Expand Down Expand Up @@ -71,13 +70,8 @@ export class JSONLanguageService {
},
},

// Custom non-JSON schema completion & hover contributions
contributions: [
new TranslationFileContributions(this.documentManager),
new SchemaTranslationContributions(
this.documentManager,
this.getDefaultSchemaTranslations,
),
new JSONContributions(this.documentManager, this.getDefaultSchemaTranslations),
],
});

Expand Down
35 changes: 35 additions & 0 deletions packages/theme-language-server-common/src/json/RequestContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { LiquidHtmlNode, LiquidRawTag } from '@shopify/liquid-html-parser';
import { isError, JSONNode, SourceCodeType } from '@shopify/theme-check-common';
import {
AugmentedJsonSourceCode,
AugmentedLiquidSourceCode,
AugmentedSourceCode,
} from '../documents';

export type RequestContext = {
doc: AugmentedSourceCode;
schema?: LiquidRawTag;
parsed?: any | Error;
};

export type LiquidRequestContext = {
doc: Omit<AugmentedLiquidSourceCode, 'ast'> & { ast: LiquidHtmlNode };
schema: LiquidRawTag;
parsed: any;
};

export type JSONRequestContext = {
doc: Omit<AugmentedJsonSourceCode, 'ast'> & { ast: JSONNode };
};

export function isLiquidRequestContext(context: RequestContext): context is LiquidRequestContext {
const { doc, schema, parsed } = context;
return (
doc.type === SourceCodeType.LiquidHtml && !!schema && !isError(doc.ast) && !isError(parsed)
);
}

export function isJSONRequestContext(context: RequestContext): context is JSONRequestContext {
const { doc } = context;
return doc.type === SourceCodeType.JSON && !isError(doc.ast);
}

This file was deleted.

Loading

0 comments on commit 05b928e

Please sign in to comment.