@shopify/theme-language-server-common
Version:
<h1 align="center" style="position: relative;" > <br> <img src="https://github.com/Shopify/theme-check-vscode/blob/main/images/shopify_glyph.png?raw=true" alt="logo" width="141" height="160"> <br> Theme Language Server </h1>
488 lines • 23.4 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.startServer = void 0;
const theme_check_common_1 = require("@shopify/theme-check-common");
const vscode_languageserver_1 = require("vscode-languageserver");
const ClientCapabilities_1 = require("../ClientCapabilities");
const codeActions_1 = require("../codeActions");
const commands_1 = require("../commands");
const completions_1 = require("../completions");
const diagnostics_1 = require("../diagnostics");
const DocumentHighlightsProvider_1 = require("../documentHighlights/DocumentHighlightsProvider");
const documentLinks_1 = require("../documentLinks");
const documents_1 = require("../documents");
const formatting_1 = require("../formatting");
const hover_1 = require("../hover");
const JSONLanguageService_1 = require("../json/JSONLanguageService");
const CSSLanguageService_1 = require("../css/CSSLanguageService");
const LinkedEditingRangesProvider_1 = require("../linkedEditingRanges/LinkedEditingRangesProvider");
const RenameProvider_1 = require("../rename/RenameProvider");
const RenameHandler_1 = require("../renamed/RenameHandler");
const utils_1 = require("../utils");
const uri_1 = require("../utils/uri");
const version_1 = require("../version");
const CachedFileSystem_1 = require("./CachedFileSystem");
const Configuration_1 = require("./Configuration");
const safe_1 = require("./safe");
const defaultLogger = () => { };
/**
* The `git:` VFS does not support the `fs.readDirectory` call and makes most things break.
* `git` URIs are the ones you'd encounter when doing a git diff in VS Code. They're not
* real files, they're just a way to represent changes in a git repository. As such, I don't
* think we want to sync those in our document manager or try to offer document links, etc.
*
* A middleware would be nice but it'd be a bit of a pain to implement.
*/
const hasUnsupportedDocument = (params) => {
return ('textDocument' in params &&
'uri' in params.textDocument &&
typeof params.textDocument.uri === 'string' &&
params.textDocument.uri.startsWith('git:'));
};
/**
* This code runs in node and the browser, it can't talk to the file system
* or make requests. Stuff like that should be injected.
*
* In browser, theme-check-js wants these things:
* - fileExists(path)
* - defaultTranslations
*
* Which means we gotta provide 'em from here too!
*/
function startServer(connection, { fs: injectedFs, loadConfig, log = defaultLogger, jsonValidationSet, themeDocset: remoteThemeDocset, fetchMetafieldDefinitionsForURI, }) {
const fs = new CachedFileSystem_1.CachedFileSystem(injectedFs);
const fileExists = (0, theme_check_common_1.makeFileExists)(fs);
const clientCapabilities = new ClientCapabilities_1.ClientCapabilities();
const configuration = new Configuration_1.Configuration(connection, clientCapabilities);
const documentManager = new documents_1.DocumentManager(fs, connection, clientCapabilities, getModeForURI, isValidSchema);
const diagnosticsManager = new diagnostics_1.DiagnosticsManager(connection);
const documentLinksProvider = new documentLinks_1.DocumentLinksProvider(documentManager, findThemeRootURI);
const codeActionsProvider = new codeActions_1.CodeActionsProvider(documentManager, diagnosticsManager);
const onTypeFormattingProvider = new formatting_1.OnTypeFormattingProvider(documentManager, async function setCursorPosition(textDocument, position) {
if (!clientCapabilities.hasShowDocumentSupport)
return;
connection.sendRequest(vscode_languageserver_1.ShowDocumentRequest.type, {
uri: textDocument.uri,
takeFocus: true,
selection: {
start: position,
end: position,
},
});
});
const linkedEditingRangesProvider = new LinkedEditingRangesProvider_1.LinkedEditingRangesProvider(documentManager);
const documentHighlightProvider = new DocumentHighlightsProvider_1.DocumentHighlightsProvider(documentManager);
const renameProvider = new RenameProvider_1.RenameProvider(connection, clientCapabilities, documentManager, findThemeRootURI);
const renameHandler = new RenameHandler_1.RenameHandler(connection, clientCapabilities, documentManager, findThemeRootURI);
async function findThemeRootURI(uri) {
const rootUri = await (0, theme_check_common_1.findRoot)(uri, fileExists);
const config = await loadConfig(rootUri, fs);
return config.rootUri;
}
const getMetafieldDefinitionsForRootUri = (0, theme_check_common_1.memoize)((0, theme_check_common_1.makeGetMetafieldDefinitions)(fs), (rootUri) => rootUri);
const getMetafieldDefinitions = async (uri) => {
const rootUri = await findThemeRootURI(uri);
return getMetafieldDefinitionsForRootUri(rootUri);
};
// These are augmented here so that the caching is maintained over different runs.
const themeDocset = new theme_check_common_1.AugmentedThemeDocset(remoteThemeDocset);
const cssLanguageService = new CSSLanguageService_1.CSSLanguageService(documentManager);
const runChecks = (0, utils_1.debounce)((0, diagnostics_1.makeRunChecks)(documentManager, diagnosticsManager, {
fs,
loadConfig,
themeDocset,
jsonValidationSet,
getMetafieldDefinitions,
cssLanguageService,
}), 100);
const getTranslationsForURI = async (uri) => {
const rootURI = await findThemeRootURI(uri);
const theme = documentManager.theme(rootURI);
const getDefaultTranslations = (0, theme_check_common_1.makeGetDefaultTranslations)(fs, theme, rootURI);
const [defaultTranslations, shopifyTranslations] = await Promise.all([
getDefaultTranslations(),
themeDocset.systemTranslations(),
]);
return { ...shopifyTranslations, ...defaultTranslations };
};
const getSchemaTranslationsForURI = async (uri) => {
const rootURI = await findThemeRootURI(uri);
const theme = documentManager.theme(rootURI);
const getDefaultSchemaTranslations = (0, theme_check_common_1.makeGetDefaultSchemaTranslations)(fs, theme, rootURI);
return getDefaultSchemaTranslations();
};
const getDocDefinitionForURI = async (uri, category, name) => {
const rootUri = await findThemeRootURI(uri);
const fileUri = theme_check_common_1.path.join(rootUri, category, `${name}.liquid`);
const file = documentManager.get(fileUri);
if (!file || file.type !== theme_check_common_1.SourceCodeType.LiquidHtml || (0, theme_check_common_1.isError)(file.ast)) {
return undefined;
}
return file.getLiquidDoc();
};
const snippetFilter = ([uri]) => /\.liquid$/.test(uri) && /snippets/.test(uri);
const getSnippetNamesForURI = (0, safe_1.safe)(async (uri) => {
const rootUri = await findThemeRootURI(uri);
const snippetUris = await (0, theme_check_common_1.recursiveReadDirectory)(fs, rootUri, snippetFilter);
return snippetUris.map(uri_1.snippetName);
}, []);
const getThemeSettingsSchemaForURI = (0, safe_1.safe)(async (uri) => {
const rootUri = await findThemeRootURI(uri);
const settingsSchemaUri = theme_check_common_1.path.join(rootUri, 'config', 'settings_schema.json');
const contents = await fs.readFile(settingsSchemaUri);
const json = (0, theme_check_common_1.parseJSON)(contents);
if ((0, theme_check_common_1.isError)(json) || !Array.isArray(json)) {
throw new Error('Settings JSON file not in correct format');
}
return json;
}, []);
async function getModeForURI(uri) {
const rootUri = await (0, theme_check_common_1.findRoot)(uri, fileExists);
const config = await loadConfig(rootUri, fs);
return config.context;
}
const getThemeBlockNames = (0, safe_1.safe)(async (uri, includePrivate) => {
const rootUri = await findThemeRootURI(uri);
const blocks = await fs.readDirectory(theme_check_common_1.path.join(rootUri, 'blocks'));
const blockNames = blocks.map(([uri]) => theme_check_common_1.path.basename(uri, '.liquid'));
if (includePrivate) {
return blockNames;
}
return blockNames.filter((blockName) => !blockName.startsWith('_'));
}, []);
async function getThemeBlockSchema(uri, name) {
const rootUri = await findThemeRootURI(uri);
const blockUri = theme_check_common_1.path.join(rootUri, 'blocks', `${name}.liquid`);
const doc = documentManager.get(blockUri);
if (!doc || doc.type !== theme_check_common_1.SourceCodeType.LiquidHtml) {
return;
}
return doc.getSchema();
}
// Defined as a function to solve a circular dependency (doc manager & json
// lang service both need each other)
async function isValidSchema(uri, jsonString) {
return jsonLanguageService.isValidSchema(uri, jsonString);
}
const jsonLanguageService = new JSONLanguageService_1.JSONLanguageService(documentManager, jsonValidationSet, getSchemaTranslationsForURI, getModeForURI, getThemeBlockNames, getThemeBlockSchema);
const completionsProvider = new completions_1.CompletionsProvider({
documentManager,
themeDocset,
getTranslationsForURI,
getSnippetNamesForURI,
getThemeSettingsSchemaForURI,
log,
getThemeBlockNames,
getMetafieldDefinitions,
getDocDefinitionForURI,
});
const hoverProvider = new hover_1.HoverProvider(documentManager, themeDocset, getMetafieldDefinitions, getTranslationsForURI, getThemeSettingsSchemaForURI, getDocDefinitionForURI);
const executeCommandProvider = new commands_1.ExecuteCommandProvider(documentManager, diagnosticsManager, clientCapabilities, runChecks, connection);
const fetchMetafieldDefinitionsForWorkspaceFolders = async (folders) => {
if (!fetchMetafieldDefinitionsForURI)
return;
for (let folder of folders) {
const mode = await getModeForURI(folder.uri);
if (mode === 'theme') {
fetchMetafieldDefinitionsForURI(folder.uri);
}
}
};
connection.onInitialize((params) => {
clientCapabilities.setup(params.capabilities, params.initializationOptions);
cssLanguageService.setup(params.capabilities);
jsonLanguageService.setup(params.capabilities);
configuration.setup();
const fileOperationRegistrationOptions = {
filters: [
{
pattern: {
glob: '**/*.{liquid,json}',
},
},
{
pattern: {
glob: '**/assets/*',
},
},
],
};
const result = {
capabilities: {
textDocumentSync: {
change: vscode_languageserver_1.TextDocumentSyncKind.Full,
save: true,
openClose: true,
},
codeActionProvider: {
codeActionKinds: [...codeActions_1.CodeActionKinds],
},
completionProvider: {
triggerCharacters: ['.', '{{ ', '{% ', '<', '/', '[', '"', "'", ':', '@'],
},
documentOnTypeFormattingProvider: {
firstTriggerCharacter: ' ',
moreTriggerCharacter: ['{', '%', '-', '>'],
},
documentLinkProvider: {
resolveProvider: false,
workDoneProgress: false,
},
documentHighlightProvider: true,
linkedEditingRangeProvider: true,
renameProvider: {
prepareProvider: true,
},
executeCommandProvider: {
commands: [...commands_1.Commands],
},
hoverProvider: {
workDoneProgress: false,
},
workspace: {
workspaceFolders: {
supported: true,
changeNotifications: true,
},
fileOperations: {
didRename: fileOperationRegistrationOptions,
},
},
},
serverInfo: {
name: 'theme-language-server',
version: version_1.VERSION,
},
};
return result;
});
connection.onInitialized(() => {
log(`[SERVER] Let's roll!`);
configuration.fetchConfiguration();
configuration.registerDidChangeCapability();
configuration.registerDidChangeWatchedFilesNotification({
watchers: [
{
globPattern: '**/.shopify/*',
},
{
globPattern: '**/*.liquid',
},
{
globPattern: '**/{locales,sections,templates,customers}/*.json',
},
{
globPattern: '**/config/settings_{data,schema}.json',
},
],
});
if (clientCapabilities.hasWorkspaceFoldersSupport) {
connection.workspace.getWorkspaceFolders().then(async (folders) => {
if (!folders)
return;
fetchMetafieldDefinitionsForWorkspaceFolders(folders);
});
connection.workspace.onDidChangeWorkspaceFolders(async (params) => {
fetchMetafieldDefinitionsForWorkspaceFolders(params.added);
});
}
});
connection.onDidChangeConfiguration((_params) => {
configuration.clearCache();
});
connection.onDidOpenTextDocument(async (params) => {
if (hasUnsupportedDocument(params))
return;
const { uri, text, version } = params.textDocument;
documentManager.open(uri, text, version);
if (await configuration.shouldCheckOnOpen()) {
runChecks([uri]);
}
// The objective at the time of writing this is to make {Asset,Snippet}Rename
// fast when you eventually need it.
//
// I'm choosing the textDocument/didOpen notification as a hook because
// I'm not sure we have a better solution than this. Yes we have the
// initialize request with the workspace folders, but you might have opened
// an app folder. The root of a theme app extension would probably be
// at ${workspaceRoot}/extensions/${appExtensionName}. It'd be hard to
// figure out from the initialize request params.
//
// If we open a file that we know is liquid, then we can kind of guarantee
// we'll find a theme root and we'll preload that.
if (await configuration.shouldPreloadOnBoot()) {
const rootUri = await findThemeRootURI(uri);
documentManager.preload(rootUri);
}
});
connection.onDidChangeTextDocument(async (params) => {
if (hasUnsupportedDocument(params))
return;
const { uri, version } = params.textDocument;
documentManager.change(uri, params.contentChanges[0].text, version);
if (await configuration.shouldCheckOnChange()) {
runChecks([uri]);
}
else {
// The diagnostics may be stale! Clear em!
diagnosticsManager.clear(params.textDocument.uri);
}
});
connection.onDidSaveTextDocument(async (params) => {
if (hasUnsupportedDocument(params))
return;
const { uri } = params.textDocument;
if (await configuration.shouldCheckOnSave()) {
runChecks([uri]);
}
});
connection.onDidCloseTextDocument((params) => {
if (hasUnsupportedDocument(params))
return;
const { uri } = params.textDocument;
documentManager.close(uri);
diagnosticsManager.clear(uri);
});
connection.onDocumentLinks(async (params) => {
if (hasUnsupportedDocument(params))
return [];
return documentLinksProvider.documentLinks(params.textDocument.uri);
});
connection.onCodeAction(async (params) => {
return codeActionsProvider.codeActions(params);
});
connection.onExecuteCommand(async (params) => {
await executeCommandProvider.execute(params);
});
connection.onCompletion(async (params) => {
var _a, _b;
if (hasUnsupportedDocument(params))
return [];
return ((_b = (_a = (await cssLanguageService.completions(params))) !== null && _a !== void 0 ? _a : (await jsonLanguageService.completions(params))) !== null && _b !== void 0 ? _b : (await completionsProvider.completions(params)));
});
connection.onHover(async (params) => {
var _a, _b;
if (hasUnsupportedDocument(params))
return null;
return ((_b = (_a = (await cssLanguageService.hover(params))) !== null && _a !== void 0 ? _a : (await jsonLanguageService.hover(params))) !== null && _b !== void 0 ? _b : (await hoverProvider.hover(params)));
});
connection.onDocumentOnTypeFormatting(async (params) => {
if (hasUnsupportedDocument(params))
return null;
return onTypeFormattingProvider.onTypeFormatting(params);
});
connection.onDocumentHighlight(async (params) => {
if (hasUnsupportedDocument(params))
return [];
return documentHighlightProvider.documentHighlights(params);
});
connection.onPrepareRename(async (params) => {
if (hasUnsupportedDocument(params))
return null;
return renameProvider.prepare(params);
});
connection.onRenameRequest(async (params) => {
if (hasUnsupportedDocument(params))
return null;
return renameProvider.rename(params);
});
connection.languages.onLinkedEditingRange(async (params) => {
if (hasUnsupportedDocument(params))
return null;
return linkedEditingRangesProvider.linkedEditingRanges(params);
});
connection.workspace.onDidRenameFiles(async (params) => {
const triggerUris = params.files.map((fileRename) => fileRename.newUri);
// Behold the cache invalidation monster
for (const { oldUri, newUri } of params.files) {
// When a file is renamed, we paste the content of the old file into the
// new file in the document manager. We don't need to invalidate preload
// because that's the only thing that changed.
documentManager.rename(oldUri, newUri);
// When a file is renamed, readDirectory to the parent folder is invalidated.
fs.readDirectory.invalidate(theme_check_common_1.path.dirname(oldUri));
fs.readDirectory.invalidate(theme_check_common_1.path.dirname(newUri));
// When a file is renamed, readFile and stat for both the old and new URIs are invalidated.
fs.readFile.invalidate(oldUri);
fs.readFile.invalidate(newUri);
fs.stat.invalidate(oldUri);
fs.stat.invalidate(newUri);
}
// We should complete refactors before running theme check
await renameHandler.onDidRenameFiles(params);
// MissingAssets/MissingSnippet should be rerun when a file is deleted
// since the file rename might cause an error.
runChecks.force(triggerUris);
});
/**
* onDidChangeWatchedFiles is triggered by file operations (in or out of the editor).
*
* For in-editor changes, happens redundantly with
* - onDidCreateFiles
* - onDidRenameFiles
* - onDidDeleteFiles
* - onDidSaveTextDocument
*
* Not redundant for operations that happen outside of the editor
* - git pull, checkout, reset, stash pop, etc.
* - shopify theme metafields pull
* - etc.
*
* It always runs and onDid* will never fire without a corresponding onDidChangeWatchedFiles.
*
* This is why the bulk of the cache invalidation logic is in this handler.
*/
connection.onDidChangeWatchedFiles(async (params) => {
var _a;
if (params.changes.length === 0)
return;
const triggerUris = params.changes.map((change) => change.uri);
const updates = [];
for (const change of params.changes) {
// Rename cache invalidation is handled by onDidRenameFiles
if (documentManager.hasRecentRename(change.uri)) {
documentManager.clearRecentRename(change.uri);
continue;
}
switch (change.type) {
case vscode_languageserver_1.FileChangeType.Created:
// A created file invalidates readDirectory, readFile and stat
fs.readDirectory.invalidate(theme_check_common_1.path.dirname(change.uri));
fs.readFile.invalidate(change.uri);
fs.stat.invalidate(change.uri);
// If a file is created under out feet, we update its contents.
updates.push(documentManager.changeFromDisk(change.uri));
break;
case vscode_languageserver_1.FileChangeType.Changed:
// A changed file invalidates readFile and stat (but not readDirectory)
fs.readFile.invalidate(change.uri);
fs.stat.invalidate(change.uri);
// If the file is not open, we update its contents in the doc manager
// If it is open, then we don't need to update it because the document manager
// will have the version from the editor.
if (((_a = documentManager.get(change.uri)) === null || _a === void 0 ? void 0 : _a.version) === undefined) {
updates.push(documentManager.changeFromDisk(change.uri));
}
break;
case vscode_languageserver_1.FileChangeType.Deleted:
// A deleted file invalides readDirectory, readFile, and stat
fs.readDirectory.invalidate(theme_check_common_1.path.dirname(change.uri));
fs.readFile.invalidate(change.uri);
fs.stat.invalidate(change.uri);
// If a file is deleted, it's removed from the document manager
documentManager.delete(change.uri);
break;
}
if (change.uri.endsWith('metafields.json')) {
updates.push(findThemeRootURI(change.uri).then((rootUri) => getMetafieldDefinitionsForRootUri.invalidate(rootUri)));
}
}
await Promise.all(updates);
// MissingAssets/MissingSnippet should be rerun when a file is deleted
// since an error might be introduced (and vice versa).
runChecks.force(triggerUris);
});
connection.listen();
}
exports.startServer = startServer;
//# sourceMappingURL=startServer.js.map