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

fix: reload content on collections change #966

Merged
merged 5 commits into from
Oct 16, 2024
Merged
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
7 changes: 7 additions & 0 deletions .changeset/empty-crabs-destroy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astrojs/language-server': patch
'@astrojs/ts-plugin': patch
'astro-vscode': patch
---

Fixes certain cases where content schemas would not be reloaded properly when they were updated
29 changes: 17 additions & 12 deletions packages/language-server/src/core/frontmatterHolders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,31 @@ const SUPPORTED_FRONTMATTER_EXTENSIONS_VALUES = Object.values(SUPPORTED_FRONTMAT
export const frontmatterRE = /^---(.*?)^---/ms;

export type CollectionConfig = {
folder: URI;
config: {
collections: {
hasSchema: boolean;
name: string;
}[];
entries: Record<string, string>;
};
reload: (folders: { uri: string }[]) => void;
configs: {
folder: URI;
config: CollectionConfigInstance;
}[];
};

export type CollectionConfigInstance = {
collections: {
hasSchema: boolean;
name: string;
}[];
entries: Record<string, string>;
};

function getCollectionName(collectionConfigs: CollectionConfig[], fileURI: string) {
for (const collection of collectionConfigs) {
function getCollectionName(collectionConfig: CollectionConfig, fileURI: string) {
for (const collection of collectionConfig.configs) {
if (collection.config.entries[fileURI]) {
return collection.config.entries[fileURI];
}
}
}

export function getFrontmatterLanguagePlugin(
collectionConfigs: CollectionConfig[],
collectionConfig: CollectionConfig,
): LanguagePlugin<URI, FrontmatterHolder> {
return {
getLanguageId(scriptId) {
Expand All @@ -55,7 +60,7 @@ export function getFrontmatterLanguagePlugin(
languageId,
snapshot,
getCollectionName(
collectionConfigs,
collectionConfig,
// The scriptId here is encoded and somewhat normalized, as such we can't use it directly to compare with
// the file URLs in the collection config entries that Astro generates.
decodeURIComponent(scriptId.toString()).toLowerCase(),
Expand Down
14 changes: 4 additions & 10 deletions packages/language-server/src/languageServerPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,21 @@ import { create as createTypescriptAddonsService } from './plugins/typescript-ad
import { create as createTypeScriptServices } from './plugins/typescript/index.js';
import { create as createYAMLService } from './plugins/yaml.js';

export function getLanguagePlugins(collectionConfigs: CollectionConfig[]) {
export function getLanguagePlugins(collectionConfig: CollectionConfig) {
const languagePlugins: LanguagePlugin<URI>[] = [
getAstroLanguagePlugin(),
getVueLanguagePlugin(),
getSvelteLanguagePlugin(),
getFrontmatterLanguagePlugin(collectionConfig),
];

if (collectionConfigs.length) {
languagePlugins.push(getFrontmatterLanguagePlugin(collectionConfigs));
}

return languagePlugins;
}

export function getLanguageServicePlugins(
connection: Connection,
ts: typeof import('typescript'),
collectionConfigs: CollectionConfig[],
collectionConfig: CollectionConfig,
) {
const LanguageServicePlugins = [
createHtmlService(),
Expand All @@ -51,12 +48,9 @@ export function getLanguageServicePlugins(
createTypescriptAddonsService(),
createAstroService(ts),
getPrettierService(),
createYAMLService(collectionConfig),
];

if (collectionConfigs.length) {
LanguageServicePlugins.push(createYAMLService(collectionConfigs));
}

return LanguageServicePlugins;

function getPrettierService() {
Expand Down
41 changes: 29 additions & 12 deletions packages/language-server/src/nodeServer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
MessageType,
ShowMessageNotification,
type WorkspaceFolder,
createConnection,
createServer,
createTypeScriptProject,
Expand All @@ -9,6 +10,7 @@ import {
import { URI, Utils } from 'vscode-uri';
import {
type CollectionConfig,
type CollectionConfigInstance,
SUPPORTED_FRONTMATTER_EXTENSIONS_KEYS,
} from './core/frontmatterHolders.js';
import { addAstroTypes } from './core/index.js';
Expand All @@ -34,15 +36,20 @@ connection.onInitialize((params) => {
const { typescript, diagnosticMessages } = loadTsdkByPath(tsdk, params.locale);

contentIntellisenseEnabled = params.initializationOptions?.contentIntellisense ?? false;
let collectionConfigs: { folder: URI; config: CollectionConfig['config'] }[] = [];

if (contentIntellisenseEnabled) {
// The vast majority of clients support workspaceFolders, but notably our tests currently don't
// Ref: https://github.com/volarjs/volar.js/issues/229
const folders =
params.workspaceFolders ?? (params.rootUri ? [{ uri: params.rootUri }] : []) ?? [];

collectionConfigs = folders.flatMap((folder) => {
const collectionConfig = {
reload(folders) {
this.configs = loadCollectionConfig(folders);
},
configs: contentIntellisenseEnabled
? loadCollectionConfig(
// The vast majority of clients support workspaceFolders, but sometimes some unusual environments like tests don't
params.workspaceFolders ?? (params.rootUri ? [{ uri: params.rootUri }] : []) ?? [],
)
: [],
} satisfies CollectionConfig;

function loadCollectionConfig(folders: WorkspaceFolder[] | { uri: string }[]) {
return folders.flatMap((folder) => {
try {
const folderUri = URI.parse(folder.uri);
let config = server.fileSystem.readFile(
Expand All @@ -54,7 +61,7 @@ connection.onInitialize((params) => {
}

// `server.fs.readFile` can theoretically be async, but in practice it's always sync
const collections = JSON.parse(config as string) as CollectionConfig['config'];
const collections = JSON.parse(config as string) as CollectionConfigInstance;

return { folder: folderUri, config: collections };
} catch (err) {
Expand All @@ -69,7 +76,7 @@ connection.onInitialize((params) => {
params,
createTypeScriptProject(typescript, diagnosticMessages, ({ env }) => {
return {
languagePlugins: getLanguagePlugins(collectionConfigs),
languagePlugins: getLanguagePlugins(collectionConfig),
setup({ project }) {
const { languageServiceHost, configFileName } = project.typescript!;

Expand Down Expand Up @@ -102,7 +109,7 @@ connection.onInitialize((params) => {
},
};
}),
getLanguageServicePlugins(connection, typescript, collectionConfigs),
getLanguageServicePlugins(connection, typescript, collectionConfig),
);
});

Expand All @@ -126,6 +133,16 @@ connection.onInitialized(() => {

if (contentIntellisenseEnabled) {
extensions.push(...SUPPORTED_FRONTMATTER_EXTENSIONS_KEYS);
server.fileWatcher.watchFiles(['**/*.schema.json', '**/collections.json']);
server.fileWatcher.onDidChangeWatchedFiles(({ changes }) => {
const shouldReload = changes.some(
(change) => change.uri.endsWith('.schema.json') || change.uri.endsWith('collections.json'),
);

if (shouldReload) {
server.project.reload();
}
});
}

server.fileWatcher.watchFiles([`**/*.{${extensions.join(',')}}`]);
Expand Down
85 changes: 47 additions & 38 deletions packages/language-server/src/plugins/yaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,37 +9,41 @@ import {
SUPPORTED_FRONTMATTER_EXTENSIONS_KEYS,
} from '../core/frontmatterHolders.js';

export const create = (collectionConfigs: CollectionConfig[]): LanguageServicePlugin => {
const yamlPlugin = createYAMLService({
getLanguageSettings() {
const schemas = collectionConfigs.flatMap((workspaceCollectionConfig) => {
return workspaceCollectionConfig.config.collections.flatMap((collection) => {
return {
fileMatch: SUPPORTED_FRONTMATTER_EXTENSIONS_KEYS.map(
(ext) => `volar-embedded-content://yaml_frontmatter_${collection.name}/**/*${ext}`,
),
uri: Utils.joinPath(
workspaceCollectionConfig.folder,
'.astro/collections',
`${collection.name}.schema.json`,
).toString(),
};
});
});
type LanguageSettings = Parameters<ReturnType<Provide['yaml/languageService']>['configure']>['0'];

export function getSettings(collectionConfig: CollectionConfig): LanguageSettings {
const schemas = collectionConfig.configs.flatMap((workspaceCollectionConfig) => {
return workspaceCollectionConfig.config.collections.flatMap((collection) => {
return {
completion: true,
format: false,
hover: true,
validate: true,
customTags: [],
yamlVersion: '1.2',
isKubernetes: false,
parentSkeletonSelectedFirst: false,
disableDefaultProperties: false,
schemas: schemas,
fileMatch: SUPPORTED_FRONTMATTER_EXTENSIONS_KEYS.map(
(ext) => `volar-embedded-content://yaml_frontmatter_${collection.name}/**/*${ext}`,
),
uri: Utils.joinPath(
workspaceCollectionConfig.folder,
'.astro/collections',
`${collection.name}.schema.json`,
).toString(),
};
},
});
});

return {
completion: true,
format: false,
hover: true,
validate: true,
customTags: [],
yamlVersion: '1.2',
isKubernetes: false,
parentSkeletonSelectedFirst: false,
disableDefaultProperties: false,
schemas: schemas,
};
}

export const create = (collectionConfig: CollectionConfig): LanguageServicePlugin => {
const yamlPlugin = createYAMLService({
getLanguageSettings: () => getSettings(collectionConfig),
}) as LanguageServicePlugin<Provide>;

return {
Expand All @@ -54,18 +58,23 @@ export const create = (collectionConfigs: CollectionConfig[]): LanguageServicePl
const languageService = yamlPluginInstance.provide?.['yaml/languageService']();
if (languageService && context.env.onDidChangeWatchedFiles) {
context.env.onDidChangeWatchedFiles(async (events) => {
let hasChanges = false;

for (const change of events.changes) {
if (!change.uri.endsWith('.schema.json')) return;

if (languageService.resetSchema(change.uri)) {
hasChanges = true;
}
const changedSchemas = events.changes.filter((change) =>
change.uri.endsWith('.schema.json'),
);
const changedConfig = events.changes.some((change) =>
change.uri.endsWith('collections.json'),
);

if (changedConfig) {
collectionConfig.reload(
// For some reason, context.env.workspaceFolders is not an array of WorkspaceFolders nor the older format, strange
context.env.workspaceFolders.map((folder) => ({ uri: folder.toString() })),
);
languageService.configure(getSettings(collectionConfig));
}

if (hasChanges) {
// TODO: Figure out how to refresh the diagnostics
for (const change of changedSchemas) {
languageService.resetSchema(change.uri);
}
});
}
Expand Down
59 changes: 59 additions & 0 deletions packages/language-server/test/content-intellisense/caching.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import fs from 'node:fs';
import path from 'node:path';
import { Position } from '@volar/language-server';
import { expect } from 'chai';
import { before, describe, it } from 'mocha';
import { URI } from 'vscode-uri';
import { type LanguageServer, getLanguageServer } from '../server.js';
import { fixtureDir } from '../utils.js';

const contentSchemaPath = path.resolve(fixtureDir, '.astro', 'collections', 'caching.schema.json');

describe('Content Intellisense - Caching', async () => {
let languageServer: LanguageServer;

before(async () => (languageServer = await getLanguageServer()));

it('Properly updates the schema when they are updated', async () => {
const document = await languageServer.handle.openTextDocument(
path.resolve(__dirname, '..', 'fixture', 'src', 'content', 'caching', 'caching.md'),
'markdown',
);

const hover = await languageServer.handle.sendHoverRequest(document.uri, Position.create(1, 1));

expect(hover?.contents).to.deep.equal({
kind: 'markdown',
value: 'I will be changed',
});

fs.writeFileSync(
contentSchemaPath,
fs.readFileSync(contentSchemaPath, 'utf-8').replaceAll('I will be changed', 'I am changed'),
);

await languageServer.handle.didChangeWatchedFiles([
{
uri: URI.file(contentSchemaPath).toString(),
type: 2,
},
]);

const hover2 = await languageServer.handle.sendHoverRequest(
document.uri,
Position.create(1, 1),
);

expect(hover2?.contents).to.deep.equal({
kind: 'markdown',
value: 'I am changed',
});
});

after(async () => {
fs.writeFileSync(
contentSchemaPath,
fs.readFileSync(contentSchemaPath, 'utf-8').replaceAll('I am changed', 'I will be changed'),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
title: 'A title'
---
9 changes: 8 additions & 1 deletion packages/language-server/test/fixture/src/content/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,11 @@ const blog = defineCollection({
}),
});

export const collections = { blog };
const caching = defineCollection({
type: 'content',
schema: z.object({
title: z.string().describe("I will be changed"),
}),
});

export const collections = { blog, caching };
3 changes: 1 addition & 2 deletions packages/language-server/test/typescript/caching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ import { expect } from 'chai';
import { mkdir, rm, writeFile } from 'fs/promises';
import { URI } from 'vscode-uri';
import { type LanguageServer, getLanguageServer } from '../server.js';

const fixtureDir = path.join(__dirname, '../fixture');
import { fixtureDir } from '../utils.js';

describe('TypeScript - Cache invalidation', async () => {
let languageServer: LanguageServer;
Expand Down
3 changes: 1 addition & 2 deletions packages/language-server/test/typescript/completions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import { Position } from '@volar/language-server';
import { expect } from 'chai';
import { before, describe, it } from 'mocha';
import { type LanguageServer, getLanguageServer } from '../server.js';

const fixtureDir = path.join(__dirname, '../fixture');
import { fixtureDir } from '../utils.js';

describe('TypeScript - Completions', async () => {
let languageServer: LanguageServer;
Expand Down
Loading