Skip to content

Commit

Permalink
fix: reload content on collections change (#966)
Browse files Browse the repository at this point in the history
* fix: reload content on collections change

* feat: reload the project on collections changes

* test: add test

* chore: changeset
  • Loading branch information
Princesseuh authored Oct 16, 2024
1 parent f1bdeab commit 8673fa5
Show file tree
Hide file tree
Showing 14 changed files with 187 additions and 84 deletions.
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

0 comments on commit 8673fa5

Please sign in to comment.