diff --git a/.changeset/empty-crabs-destroy.md b/.changeset/empty-crabs-destroy.md new file mode 100644 index 00000000..783f9242 --- /dev/null +++ b/.changeset/empty-crabs-destroy.md @@ -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 diff --git a/packages/language-server/src/core/frontmatterHolders.ts b/packages/language-server/src/core/frontmatterHolders.ts index 22f3beb5..b3a152c6 100644 --- a/packages/language-server/src/core/frontmatterHolders.ts +++ b/packages/language-server/src/core/frontmatterHolders.ts @@ -15,18 +15,23 @@ 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; - }; + reload: (folders: { uri: string }[]) => void; + configs: { + folder: URI; + config: CollectionConfigInstance; + }[]; +}; + +export type CollectionConfigInstance = { + collections: { + hasSchema: boolean; + name: string; + }[]; + entries: Record; }; -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]; } @@ -34,7 +39,7 @@ function getCollectionName(collectionConfigs: CollectionConfig[], fileURI: strin } export function getFrontmatterLanguagePlugin( - collectionConfigs: CollectionConfig[], + collectionConfig: CollectionConfig, ): LanguagePlugin { return { getLanguageId(scriptId) { @@ -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(), diff --git a/packages/language-server/src/languageServerPlugin.ts b/packages/language-server/src/languageServerPlugin.ts index 778d2305..134a9252 100644 --- a/packages/language-server/src/languageServerPlugin.ts +++ b/packages/language-server/src/languageServerPlugin.ts @@ -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[] = [ 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(), @@ -51,12 +48,9 @@ export function getLanguageServicePlugins( createTypescriptAddonsService(), createAstroService(ts), getPrettierService(), + createYAMLService(collectionConfig), ]; - if (collectionConfigs.length) { - LanguageServicePlugins.push(createYAMLService(collectionConfigs)); - } - return LanguageServicePlugins; function getPrettierService() { diff --git a/packages/language-server/src/nodeServer.ts b/packages/language-server/src/nodeServer.ts index fafbd38b..71f57edb 100644 --- a/packages/language-server/src/nodeServer.ts +++ b/packages/language-server/src/nodeServer.ts @@ -1,6 +1,7 @@ import { MessageType, ShowMessageNotification, + type WorkspaceFolder, createConnection, createServer, createTypeScriptProject, @@ -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'; @@ -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( @@ -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) { @@ -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!; @@ -102,7 +109,7 @@ connection.onInitialize((params) => { }, }; }), - getLanguageServicePlugins(connection, typescript, collectionConfigs), + getLanguageServicePlugins(connection, typescript, collectionConfig), ); }); @@ -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(',')}}`]); diff --git a/packages/language-server/src/plugins/yaml.ts b/packages/language-server/src/plugins/yaml.ts index a86b82d3..e36fb96a 100644 --- a/packages/language-server/src/plugins/yaml.ts +++ b/packages/language-server/src/plugins/yaml.ts @@ -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['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; return { @@ -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); } }); } diff --git a/packages/language-server/test/content-intellisense/caching.test.ts b/packages/language-server/test/content-intellisense/caching.test.ts new file mode 100644 index 00000000..44dffdd3 --- /dev/null +++ b/packages/language-server/test/content-intellisense/caching.test.ts @@ -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'), + ); + }); +}); diff --git a/packages/language-server/test/fixture/src/content/caching/caching.md b/packages/language-server/test/fixture/src/content/caching/caching.md new file mode 100644 index 00000000..b3bb2c72 --- /dev/null +++ b/packages/language-server/test/fixture/src/content/caching/caching.md @@ -0,0 +1,3 @@ +--- +title: 'A title' +--- diff --git a/packages/language-server/test/fixture/src/content/config.ts b/packages/language-server/test/fixture/src/content/config.ts index d144ec5e..567285fa 100644 --- a/packages/language-server/test/fixture/src/content/config.ts +++ b/packages/language-server/test/fixture/src/content/config.ts @@ -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 }; diff --git a/packages/language-server/test/typescript/caching.test.ts b/packages/language-server/test/typescript/caching.test.ts index 4bd4d9b6..c5c41ecf 100644 --- a/packages/language-server/test/typescript/caching.test.ts +++ b/packages/language-server/test/typescript/caching.test.ts @@ -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; diff --git a/packages/language-server/test/typescript/completions.test.ts b/packages/language-server/test/typescript/completions.test.ts index c5d596c5..51443673 100644 --- a/packages/language-server/test/typescript/completions.test.ts +++ b/packages/language-server/test/typescript/completions.test.ts @@ -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; diff --git a/packages/language-server/test/typescript/renames.test.ts b/packages/language-server/test/typescript/renames.test.ts index b034c88b..8f392888 100644 --- a/packages/language-server/test/typescript/renames.test.ts +++ b/packages/language-server/test/typescript/renames.test.ts @@ -3,8 +3,7 @@ import { expect } from 'chai'; import type { RenameFilesParams } from 'vscode-languageserver-protocol'; import { WillRenameFilesRequest } from 'vscode-languageserver-protocol'; import { type LanguageServer, getLanguageServer } from '../server.js'; - -const fixtureDir = path.join(__dirname, '../fixture'); +import { fixtureDir } from '../utils.js'; describe('TypeScript - Renaming', async () => { let languageServer: LanguageServer; diff --git a/packages/language-server/test/utils.ts b/packages/language-server/test/utils.ts index 39e0f83d..b2e13c95 100644 --- a/packages/language-server/test/utils.ts +++ b/packages/language-server/test/utils.ts @@ -1,5 +1,8 @@ +import path from 'node:path'; import type { Point, Position } from '@astrojs/compiler'; +export const fixtureDir = path.join(__dirname, './fixture'); + export function createCompilerPosition(start: Point, end: Point): Position { return { start, diff --git a/packages/ts-plugin/src/frontmatter.ts b/packages/ts-plugin/src/frontmatter.ts index 5c54fe66..8b37e387 100644 --- a/packages/ts-plugin/src/frontmatter.ts +++ b/packages/ts-plugin/src/frontmatter.ts @@ -25,8 +25,8 @@ export type CollectionConfig = { }; }; -function getCollectionName(collectionConfigs: CollectionConfig[], fsPath: string) { - for (const collection of collectionConfigs) { +function getCollectionName(collectionConfig: CollectionConfig[], fsPath: string) { + for (const collection of collectionConfig) { if (collection.config.entries[fsPath]) { return collection.config.entries[fsPath]; } @@ -34,7 +34,7 @@ function getCollectionName(collectionConfigs: CollectionConfig[], fsPath: string } export function getFrontmatterLanguagePlugin( - collectionConfigs: CollectionConfig[], + collectionConfig: CollectionConfig[], ): LanguagePlugin { return { getLanguageId(scriptId) { @@ -57,7 +57,7 @@ export function getFrontmatterLanguagePlugin( snapshot, // In TypeScript plugins, unlike in the language server, the scriptId is just a string file path // so we'll have to convert it to a URL to match the collection config entries - getCollectionName(collectionConfigs, pathToFileURL(fileName).toString().toLowerCase()), + getCollectionName(collectionConfig, pathToFileURL(fileName).toString().toLowerCase()), ); } }, diff --git a/packages/vscode/src/client.ts b/packages/vscode/src/client.ts index 6c1c0f4f..8943ac93 100644 --- a/packages/vscode/src/client.ts +++ b/packages/vscode/src/client.ts @@ -39,7 +39,9 @@ export async function activate(context: vscode.ExtensionContext): Promise