diff --git a/packages/theme-check-common/vitest.config.ts b/packages/theme-check-common/vitest.config.ts new file mode 100644 index 000000000..67d1b0b12 --- /dev/null +++ b/packages/theme-check-common/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; +import { configDefaults } from 'vitest/config'; + +export default defineConfig({ + test: { + exclude: [...configDefaults.exclude], + pool: 'forks', + poolOptions: { + forks: { + singleFork: true, + isolate: true, + }, + }, + setupFiles: ['./src/test/test-setup.ts'], + }, +}); diff --git a/packages/theme-language-server-common/src/renamed/handlers/SectionRenameHandler.spec.ts b/packages/theme-language-server-common/src/renamed/handlers/SectionRenameHandler.spec.ts index 2b8da2c2b..16471fa3c 100644 --- a/packages/theme-language-server-common/src/renamed/handlers/SectionRenameHandler.spec.ts +++ b/packages/theme-language-server-common/src/renamed/handlers/SectionRenameHandler.spec.ts @@ -230,6 +230,232 @@ describe('Module: SectionRenameHandler', () => { } }); + describe('config/settings_data.json handling', () => { + const settingsDataUri = 'mock-fs:/config/settings_data.json'; + + it('updates the type attribute in config/settings_data.json when a section is renamed', async () => { + fs = new MockFileSystem( + { + 'sections/old-name.liquid': ` + {% schema %} + { "name": "Old name" } + {% endschema %}`, + + 'config/settings_data.json': JSON.stringify( + { + presets: { + Default: { + sections: { + 'header-id': { type: 'old-name', settings: {} }, + }, + order: ['header-id'], + }, + }, + }, + null, + 2, + ), + }, + mockRoot, + ); + documentManager = new DocumentManager( + fs, + undefined, + undefined, + async () => 'theme', + async () => true, + ); + handler = new RenameHandler(connection, capabilities, documentManager, findThemeRootURI); + + await handler.onDidRenameFiles({ + files: [ + { + oldUri: 'mock-fs:/sections/old-name.liquid', + newUri: 'mock-fs:/sections/new-name.liquid', + }, + ], + }); + + expect(connection.spies.sendRequest).toHaveBeenCalledWith( + 'workspace/applyEdit', + expect.objectContaining({ + edit: expect.objectContaining({ + documentChanges: expect.arrayContaining([ + { + textDocument: { uri: settingsDataUri, version: null }, + edits: [ + { + annotationId: 'renameSection', + newText: 'new-name', + range: { start: expect.any(Object), end: expect.any(Object) }, + }, + ], + }, + ]), + }), + }), + ); + }); + + it('replaces the correct text in config/settings_data.json', async () => { + const settingsDataContent = JSON.stringify( + { + presets: { + Default: { + sections: { + 'header-id': { type: 'old-name', settings: {} }, + }, + order: ['header-id'], + }, + }, + }, + null, + 2, + ); + fs = new MockFileSystem( + { + 'sections/old-name.liquid': ` + {% schema %} + { "name": "Old name" } + {% endschema %}`, + + 'config/settings_data.json': settingsDataContent, + }, + mockRoot, + ); + documentManager = new DocumentManager( + fs, + undefined, + undefined, + async () => 'theme', + async () => true, + ); + handler = new RenameHandler(connection, capabilities, documentManager, findThemeRootURI); + + await handler.onDidRenameFiles({ + files: [ + { + oldUri: 'mock-fs:/sections/old-name.liquid', + newUri: 'mock-fs:/sections/new-name.liquid', + }, + ], + }); + + const params: ApplyWorkspaceEditParams = connection.spies.sendRequest.mock.calls[0][1]; + const expectedFs = new MockFileSystem( + { + 'config/settings_data.json': JSON.stringify( + { + presets: { + Default: { + sections: { + 'header-id': { type: 'new-name', settings: {} }, + }, + order: ['header-id'], + }, + }, + }, + null, + 2, + ), + }, + mockRoot, + ); + + assert(params.edit); + assert(params.edit.documentChanges); + + for (const docChange of params.edit.documentChanges) { + assert(TextDocumentEdit.is(docChange)); + const uri = docChange.textDocument.uri; + const edits = docChange.edits; + const initialDoc = await fs.readFile(uri); + const expectedDoc = await expectedFs.readFile(uri); + expect(edits).to.applyEdits(initialDoc, expectedDoc); + } + }); + + it('completes without error when config/settings_data.json does not exist', async () => { + // The default beforeEach fs has no settings_data.json. + // Rename should still update templates, section groups, and liquid tags. + await handler.onDidRenameFiles({ + files: [ + { + oldUri: 'mock-fs:/sections/old-name.liquid', + newUri: 'mock-fs:/sections/new-name.liquid', + }, + ], + }); + + expect(connection.spies.sendRequest).toHaveBeenCalled(); + const params: ApplyWorkspaceEditParams = connection.spies.sendRequest.mock.calls[0][1]; + assert(params.edit.documentChanges); + const uris = (params.edit.documentChanges as TextDocumentEdit[]).map( + (dc) => dc.textDocument.uri, + ); + expect(uris).not.toContain(settingsDataUri); + }); + + it('does not edit config/settings_data.json when section name is not present', async () => { + fs = new MockFileSystem( + { + 'sections/old-name.liquid': ` + {% schema %} + { "name": "Old name" } + {% endschema %}`, + + 'templates/index.json': ` + { + "sections": { "s": { "type": "old-name" } }, + "order": ["s"] + }`, + + 'config/settings_data.json': JSON.stringify( + { + presets: { + Default: { + sections: { + 'header-id': { type: 'some-other-section', settings: {} }, + }, + order: ['header-id'], + }, + }, + }, + null, + 2, + ), + }, + mockRoot, + ); + documentManager = new DocumentManager( + fs, + undefined, + undefined, + async () => 'theme', + async () => true, + ); + handler = new RenameHandler(connection, capabilities, documentManager, findThemeRootURI); + + await handler.onDidRenameFiles({ + files: [ + { + oldUri: 'mock-fs:/sections/old-name.liquid', + newUri: 'mock-fs:/sections/new-name.liquid', + }, + ], + }); + + // sendRequest was called (for the template change), but not for settings_data.json + expect(connection.spies.sendRequest).toHaveBeenCalled(); + const params: ApplyWorkspaceEditParams = connection.spies.sendRequest.mock.calls[0][1]; + assert(params.edit.documentChanges); + const uris = (params.edit.documentChanges as TextDocumentEdit[]).map( + (dc) => dc.textDocument.uri, + ); + expect(uris).not.toContain(settingsDataUri); + }); + }); + it('preserves local section definitions', async () => { fs = new MockFileSystem( { diff --git a/packages/theme-language-server-common/src/renamed/handlers/SectionRenameHandler.ts b/packages/theme-language-server-common/src/renamed/handlers/SectionRenameHandler.ts index 07bee8d42..068aad24b 100644 --- a/packages/theme-language-server-common/src/renamed/handlers/SectionRenameHandler.ts +++ b/packages/theme-language-server-common/src/renamed/handlers/SectionRenameHandler.ts @@ -28,9 +28,9 @@ import { isLiquidSourceCode, } from '../../documents'; import { FindThemeRootURI } from '../../internal-types'; -import { isSection, isSectionGroup, isTemplate, sectionName } from '../../utils/uri'; +import { isSection, isSectionGroup, isSettingsData, isTemplate, sectionName } from '../../utils/uri'; import { BaseRenameHandler } from '../BaseRenameHandler'; -import { isValidSectionGroup, isValidTemplate } from './utils'; +import { isValidSectionGroup, isValidSettingsData, isValidTemplate } from './utils'; type DocumentChange = TextDocumentEdit; @@ -69,6 +69,7 @@ export class SectionRenameHandler implements BaseRenameHandler { const liquidFiles = theme.filter(isLiquidSourceCode); const templates = theme.filter(isJsonSourceCode).filter((file) => isTemplate(file.uri)); const sectionGroups = theme.filter(isJsonSourceCode).filter((file) => isSectionGroup(file.uri)); + const settingsDataFiles = theme.filter(isJsonSourceCode).filter((file) => isSettingsData(file.uri)); const oldSectionName = sectionName(rename.oldUri); const newSectionName = sectionName(rename.newUri); const editLabel = `Rename section '${oldSectionName}' to '${newSectionName}'`; @@ -84,13 +85,14 @@ export class SectionRenameHandler implements BaseRenameHandler { // All the templates/*.json files need to be updated with the new block name // when the old block name wasn't a local block. - const [templateChanges, sectionGroupChanges, sectionTagChanges] = await Promise.all([ + const [templateChanges, sectionGroupChanges, sectionTagChanges, settingsDataChanges] = await Promise.all([ Promise.all(templates.map(this.getTemplateChanges(oldSectionName, newSectionName))), Promise.all(sectionGroups.map(this.getSectionGroupChanges(oldSectionName, newSectionName))), Promise.all(liquidFiles.map(this.getSectionTagChanges(oldSectionName, newSectionName))), + Promise.all(settingsDataFiles.map(this.getSettingsDataChanges(oldSectionName, newSectionName))), ]); - for (const docChange of [...templateChanges, ...sectionGroupChanges]) { + for (const docChange of [...templateChanges, ...sectionGroupChanges, ...settingsDataChanges]) { if (docChange !== null) { workspaceEdit.documentChanges!.push(docChange); } @@ -179,6 +181,42 @@ export class SectionRenameHandler implements BaseRenameHandler { }; } + private getSettingsDataChanges(oldSectionName: string, newSectionName: string) { + return async (sourceCode: AugmentedJsonSourceCode) => { + const { textDocument, ast, source } = sourceCode; + const parsed = parseJSON(source); + if (!parsed || isError(parsed) || isError(ast)) return null; + if (!isValidSettingsData(parsed)) return null; + + const edits: AnnotatedTextEdit[] = Object.entries(parsed.presets ?? {}).flatMap( + ([presetName, preset]) => + Object.entries(preset.sections ?? {}) + .filter(([_key, section]) => section.type === oldSectionName) + .map(([sectionKey]) => { + const node = nodeAtPath(ast, [ + 'presets', + presetName, + 'sections', + sectionKey, + 'type', + ]) as LiteralNode; + return { + annotationId, + newText: newSectionName, + range: Range.create( + textDocument.positionAt(node.loc.start.offset + 1), + textDocument.positionAt(node.loc.end.offset - 1), + ), + } as AnnotatedTextEdit; + }), + ); + + if (edits.length === 0) return null; + + return documentChanges(sourceCode, edits); + }; + } + private getSectionTagChanges(oldSectionName: string, newSectionName: string) { return async (sourceCode: AugmentedLiquidSourceCode) => { const { textDocument, ast } = sourceCode; diff --git a/packages/theme-language-server-common/src/renamed/handlers/utils.ts b/packages/theme-language-server-common/src/renamed/handlers/utils.ts index df1c68087..e8d838d1f 100644 --- a/packages/theme-language-server-common/src/renamed/handlers/utils.ts +++ b/packages/theme-language-server-common/src/renamed/handlers/utils.ts @@ -1,5 +1,22 @@ import { Template } from '@shopify/theme-check-common'; +export interface SettingsDataPreset { + sections?: Record; +} + +export interface SettingsData { + presets?: Record; +} + +export function isValidSettingsData(parsed: unknown): parsed is SettingsData { + return ( + typeof parsed === 'object' && + parsed !== null && + 'presets' in parsed && + typeof (parsed as SettingsData).presets === 'object' + ); +} + // this is very very optimistic... export function isValidTemplate(parsed: unknown): parsed is Template.Template { return ( diff --git a/packages/theme-language-server-common/src/utils/uri.ts b/packages/theme-language-server-common/src/utils/uri.ts index bd9334a32..56c230030 100644 --- a/packages/theme-language-server-common/src/utils/uri.ts +++ b/packages/theme-language-server-common/src/utils/uri.ts @@ -20,3 +20,5 @@ export const isSectionGroup = (uri: string) => export const templateName = (uri: string) => path.basename(uri, '.json'); export const isTemplate = (uri: string) => /\btemplates(\\|\/)[^\\\/]/.test(uri); + +export const isSettingsData = (uri: string) => /\bconfig(\\|\/)settings_data\.json$/.test(uri); diff --git a/packages/theme-language-server-common/vitest.config.ts b/packages/theme-language-server-common/vitest.config.ts new file mode 100644 index 000000000..b28b7c703 --- /dev/null +++ b/packages/theme-language-server-common/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; +import { configDefaults } from 'vitest/config'; + +export default defineConfig({ + test: { + exclude: [...configDefaults.exclude], + pool: 'forks', + poolOptions: { + forks: { + singleFork: true, + isolate: true, + }, + }, + setupFiles: [ + '../theme-check-common/src/test/test-setup.ts', + './src/test/test-setup.ts', + ], + }, +});