@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>
418 lines (382 loc) • 14.1 kB
text/typescript
import {
AugmentedThemeDocset,
FileTuple,
findRoot,
isError,
makeFileExists,
makeGetDefaultSchemaTranslations,
makeGetDefaultTranslations,
parseJSON,
path,
recursiveReadDirectory,
} from '@shopify/theme-check-common';
import {
Connection,
FileOperationRegistrationOptions,
InitializeResult,
ShowDocumentRequest,
TextDocumentSyncKind,
} from 'vscode-languageserver';
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 { DocumentHighlightsProvider } from '../documentHighlights/DocumentHighlightsProvider';
import { DocumentLinksProvider } from '../documentLinks';
import { DocumentManager } from '../documents';
import { OnTypeFormattingProvider } from '../formatting';
import { HoverProvider } from '../hover';
import { JSONLanguageService } from '../json/JSONLanguageService';
import { LinkedEditingRangesProvider } from '../linkedEditingRanges/LinkedEditingRangesProvider';
import { RenameProvider } from '../rename/RenameProvider';
import { RenameHandler } from '../renamed/RenameHandler';
import { GetTranslationsForURI } from '../translations';
import { Dependencies } from '../types';
import { debounce } from '../utils';
import { snippetName } from '../utils/uri';
import { VERSION } from '../version';
import { CachedFileSystem } from './CachedFileSystem';
import { Configuration } from './Configuration';
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: any) => {
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!
*/
export function startServer(
connection: Connection,
{
fs: injectedFs,
loadConfig,
log = defaultLogger,
jsonValidationSet,
themeDocset: remoteThemeDocset,
}: Dependencies,
) {
const fs = new CachedFileSystem(injectedFs);
const fileExists = makeFileExists(fs);
const clientCapabilities = new ClientCapabilities();
const configuration = new Configuration(connection, clientCapabilities);
const documentManager = new DocumentManager(fs);
const diagnosticsManager = new DiagnosticsManager(connection);
const documentLinksProvider = new DocumentLinksProvider(documentManager, findThemeRootURI);
const codeActionsProvider = new CodeActionsProvider(documentManager, diagnosticsManager);
const onTypeFormattingProvider = new OnTypeFormattingProvider(
documentManager,
async function setCursorPosition(textDocument, position) {
if (!clientCapabilities.hasShowDocumentSupport) return;
connection.sendRequest(ShowDocumentRequest.type, {
uri: textDocument.uri,
takeFocus: true,
selection: {
start: position,
end: position,
},
});
},
);
const linkedEditingRangesProvider = new LinkedEditingRangesProvider(documentManager);
const documentHighlightProvider = new DocumentHighlightsProvider(documentManager);
const renameProvider = new RenameProvider(documentManager);
const renameHandler = new RenameHandler(connection, documentManager, fileExists);
async function findThemeRootURI(uri: string) {
const rootUri = await findRoot(uri, fileExists);
const config = await loadConfig(rootUri, fs);
return config.rootUri;
}
// These are augmented here so that the caching is maintained over different runs.
const themeDocset = new AugmentedThemeDocset(remoteThemeDocset);
const runChecks = debounce(
makeRunChecks(documentManager, diagnosticsManager, {
fs,
loadConfig,
themeDocset,
jsonValidationSet,
}),
100,
);
const getTranslationsForURI: GetTranslationsForURI = async (uri) => {
const rootURI = await findThemeRootURI(uri);
const theme = documentManager.theme(rootURI);
const getDefaultTranslations = makeGetDefaultTranslations(fs, theme, rootURI);
const [defaultTranslations, shopifyTranslations] = await Promise.all([
getDefaultTranslations(),
themeDocset.systemTranslations(),
]);
return { ...shopifyTranslations, ...defaultTranslations };
};
const getSchemaTranslationsForURI: GetTranslationsForURI = async (uri) => {
const rootURI = await findThemeRootURI(uri);
const theme = documentManager.theme(rootURI);
const getDefaultSchemaTranslations = makeGetDefaultSchemaTranslations(fs, theme, rootURI);
return getDefaultSchemaTranslations();
};
const snippetFilter = ([uri]: FileTuple) => /\.liquid$/.test(uri) && /snippets/.test(uri);
const getSnippetNamesForURI: GetSnippetNamesForURI = async (uri: string) => {
const rootUri = await findThemeRootURI(uri);
const snippetUris = await recursiveReadDirectory(fs, rootUri, snippetFilter);
return snippetUris.map(snippetName);
};
const getThemeSettingsSchemaForURI = async (uri: string) => {
try {
const rootUri = await findThemeRootURI(uri);
const settingsSchemaUri = path.join(rootUri, 'config', 'settings_schema.json');
const contents = await fs.readFile(settingsSchemaUri);
const json = parseJSON(contents);
if (isError(json) || !Array.isArray(json)) {
throw new Error('Settings JSON file not in correct format');
}
return json;
} catch (error) {
console.error(error);
return [];
}
};
const getModeForURI = async (uri: string) => {
const rootUri = await findRoot(uri, fileExists);
const config = await loadConfig(rootUri, fs);
return config.context;
};
const jsonLanguageService = new JSONLanguageService(
documentManager,
jsonValidationSet,
getSchemaTranslationsForURI,
getModeForURI,
);
const completionsProvider = new CompletionsProvider({
documentManager,
themeDocset,
getTranslationsForURI,
getSnippetNamesForURI,
getThemeSettingsSchemaForURI,
log,
});
const hoverProvider = new HoverProvider(
documentManager,
themeDocset,
getTranslationsForURI,
getThemeSettingsSchemaForURI,
);
const executeCommandProvider = new ExecuteCommandProvider(
documentManager,
diagnosticsManager,
clientCapabilities,
runChecks,
connection,
);
connection.onInitialize((params) => {
clientCapabilities.setup(params.capabilities, params.initializationOptions);
jsonLanguageService.setup(params.capabilities);
configuration.setup();
const fileOperationRegistrationOptions: FileOperationRegistrationOptions = {
filters: [
{
pattern: {
glob: '**/*.{liquid,json}',
},
},
{
pattern: {
glob: '**/assets/*',
},
},
],
};
const result: InitializeResult = {
capabilities: {
textDocumentSync: {
change: TextDocumentSyncKind.Full,
save: true,
openClose: true,
},
codeActionProvider: {
codeActionKinds: [...CodeActionKinds],
},
completionProvider: {
triggerCharacters: ['.', '{{ ', '{% ', '<', '/', '[', '"', "'", ':'],
},
documentOnTypeFormattingProvider: {
firstTriggerCharacter: ' ',
moreTriggerCharacter: ['{', '%', '-', '>'],
},
documentLinkProvider: {
resolveProvider: false,
workDoneProgress: false,
},
documentHighlightProvider: true,
linkedEditingRangeProvider: true,
renameProvider: {
prepareProvider: true,
},
executeCommandProvider: {
commands: [...Commands],
},
hoverProvider: {
workDoneProgress: false,
},
workspace: {
fileOperations: {
didRename: fileOperationRegistrationOptions,
didCreate: fileOperationRegistrationOptions,
didDelete: fileOperationRegistrationOptions,
},
},
},
serverInfo: {
name: 'theme-language-server',
version: VERSION,
},
};
return result;
});
connection.onInitialized(() => {
log(`[SERVER] Let's roll!`);
configuration.fetchConfiguration();
configuration.registerDidChangeCapability();
});
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]);
}
});
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;
fs.readFile.invalidate(uri);
fs.stat.invalidate(uri);
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) => {
if (hasUnsupportedDocument(params)) return [];
return (
(await jsonLanguageService.completions(params)) ??
(await completionsProvider.completions(params))
);
});
connection.onHover(async (params) => {
if (hasUnsupportedDocument(params)) return null;
return (await jsonLanguageService.hover(params)) ?? (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);
});
// These notifications could cause a MissingSnippet check to be invalidated
//
// It is not guaranteed that the file is or was opened when it was
// created/renamed/deleted. If we're smart, we're going to re-lint for
// every root affected. Unfortunately, we can't just use the debounced
// call because we could run in a weird timing issue where didOpen
// happens after the didRename and causes the 'lastArgs' to skip over the
// ones we were after.
//
// So we're using runChecks.force for that.
connection.workspace.onDidCreateFiles((params) => {
const triggerUris = params.files.map((fileCreate) => fileCreate.uri);
runChecks.force(triggerUris);
for (const { uri } of params.files) {
fs.readDirectory.invalidate(path.dirname(uri));
fs.readFile.invalidate(uri);
fs.stat.invalidate(uri);
}
});
connection.workspace.onDidRenameFiles((params) => {
const triggerUris = params.files.map((fileRename) => fileRename.newUri);
renameHandler.onDidRenameFiles(params);
runChecks.force(triggerUris);
for (const { oldUri, newUri } of params.files) {
documentManager.delete(oldUri);
fs.readDirectory.invalidate(path.dirname(oldUri));
fs.readDirectory.invalidate(path.dirname(newUri));
fs.readFile.invalidate(oldUri);
fs.readFile.invalidate(newUri);
fs.stat.invalidate(oldUri);
fs.stat.invalidate(newUri);
}
});
connection.workspace.onDidDeleteFiles((params) => {
const triggerUris = params.files.map((fileDelete) => fileDelete.uri);
runChecks.force(triggerUris);
for (const { uri } of params.files) {
documentManager.delete(uri);
fs.readDirectory.invalidate(path.dirname(uri));
fs.readFile.invalidate(uri);
fs.stat.invalidate(uri);
}
});
connection.listen();
}