Skip to content

Commit ce5cb33

Browse files
authored
Gracefully handle files that are not from a theme (#985)
Make findThemeRootURI return null when it can't find a root Make everywhere handle null when no root is found Should fix "preloading" of theme when looking at `$HOME/Library/Application Support/Code/User/settings.json` Fixes #973
1 parent eae6780 commit ce5cb33

File tree

25 files changed

+209
-113
lines changed

25 files changed

+209
-113
lines changed

.changeset/violet-cycles-yawn.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@shopify/theme-language-server-common': patch
3+
'@shopify/theme-language-server-node': patch
4+
'@shopify/theme-check-common': patch
5+
'theme-check-vscode': patch
6+
---
7+
8+
Gracefully handle files that are not part of a theme

packages/theme-check-common/src/checks/valid-visible-if/visible-if-utils.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,6 @@
1-
import {
2-
type LiquidVariableLookup,
3-
toLiquidHtmlAST,
4-
NodeTypes,
5-
LiquidVariableOutput,
6-
} from '@shopify/liquid-html-parser';
1+
import { type LiquidVariableLookup, NodeTypes, toLiquidHtmlAST } from '@shopify/liquid-html-parser';
72
import { Context, SourceCodeType } from '../..';
8-
import { findRoot } from '../../find-root';
93
import { parseJSON } from '../../json';
10-
import { join } from '../../path';
114
import { visit } from '../../visitor';
125

136
export type Vars = { [key: string]: Vars | true };
@@ -140,11 +133,8 @@ export async function getGlobalSettings(context: Context<SourceCodeType>) {
140133
const globalSettings: string[] = [];
141134

142135
try {
143-
const path = join(
144-
await findRoot(context.file.uri, context.fileExists),
145-
'config/settings_schema.json',
146-
);
147-
const settingsFile = await context.fs.readFile(path);
136+
const uri = context.toUri('config/settings_schema.json');
137+
const settingsFile = await context.fs.readFile(uri);
148138
const settings = parseJSON(settingsFile);
149139
if (Array.isArray(settings)) {
150140
for (const group of settings) {

packages/theme-check-common/src/find-root.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,19 @@ async function isRoot(dir: UriString, fileExists: FileExists) {
77
return or(
88
fileExists(path.join(dir, 'shopify.extension.toml')), // for theme-app-extensions
99
fileExists(path.join(dir, '.theme-check.yml')),
10-
fileExists(path.join(dir, '.git')),
1110

12-
// zip files and TAEs might not have config files, but they should have a
13-
// snippets directory but in case they do specify a .theme-check.yml a
11+
// Maybe the root of the workspace has an assets + snippets directory, we'll accept that
12+
and(
13+
fileExists(path.join(dir, '.git')),
14+
fileExists(path.join(dir, 'assets')),
15+
fileExists(path.join(dir, 'snippets')),
16+
),
17+
18+
// zip files and TAEs might not have config files, but they should have an
19+
// assets & snippets directory but in case they do specify a .theme-check.yml a
1420
// couple of directories up, we should respect that
1521
and(
22+
fileExists(path.join(dir, 'assets')),
1623
fileExists(path.join(dir, 'snippets')),
1724
not(fileExists(path.join(path.dirname(dir), '.theme-check.yml'))),
1825
not(fileExists(path.join(path.dirname(path.dirname(dir)), '.theme-check.yml'))),
@@ -49,7 +56,7 @@ async function not(ap: Promise<boolean>) {
4956
* Note: that this is not the theme root. The config file might have a `root` entry in it
5057
* that points to somewhere else.
5158
*/
52-
export async function findRoot(curr: UriString, fileExists: FileExists): Promise<UriString> {
59+
export async function findRoot(curr: UriString, fileExists: FileExists): Promise<UriString | null> {
5360
const currIsRoot = await isRoot(curr, fileExists);
5461
if (currIsRoot) {
5562
return curr;
@@ -58,7 +65,7 @@ export async function findRoot(curr: UriString, fileExists: FileExists): Promise
5865
const dir = path.dirname(curr);
5966
const currIsAbsoluteRoot = dir === curr;
6067
if (currIsAbsoluteRoot) {
61-
return curr;
68+
return null; // Root not found.
6269
}
6370

6471
return findRoot(dir, fileExists);

packages/theme-check-common/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ function createContext<T extends SourceCodeType, S extends Schema>(
144144
...dependencies,
145145
validateJSON,
146146
settings: createSettings(checkSettings, check.meta.schema),
147-
toUri: (relativePath) => path.join(config.rootUri, relativePath),
147+
toUri: (relativePath) => path.join(config.rootUri, ...relativePath.split('/')),
148148
toRelativePath: (uri) => path.relative(uri, config.rootUri),
149149
report(problem: Problem<T>): void {
150150
offenses.push({

packages/theme-check-common/src/test/MockFileSystem.ts

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AbstractFileSystem, FileStat, FileTuple, FileType } from '../AbstractFi
22
import { deepGet } from '../utils';
33
import { normalize, relative } from '../path';
44
import { MockTheme } from './MockTheme';
5+
import * as path from '../path';
56

67
interface FileTree {
78
[fileName: string]: string | FileTree;
@@ -24,14 +25,15 @@ export class MockFileSystem implements AbstractFileSystem {
2425
}
2526

2627
async readDirectory(uri: string): Promise<FileTuple[]> {
28+
// eslint-disable-next-line no-param-reassign
2729
uri = uri.replace(/\/$/, '');
2830
const relativePath = this.rootRelative(uri);
2931
const tree =
30-
normalize(uri) === this.rootUri
32+
path.normalize(uri) === this.rootUri
3133
? this.fileTree
3234
: deepGet(this.fileTree, relativePath.split('/'));
33-
if (tree === undefined) {
34-
throw new Error(`Directory not found: ${uri} in ${this.rootUri}`);
35+
if (tree === undefined || tree === null) {
36+
throw new Error(`Directory not found: ${uri} for ${this.rootUri}`);
3537
}
3638

3739
if (typeof tree === 'string') {
@@ -46,32 +48,37 @@ export class MockFileSystem implements AbstractFileSystem {
4648
async stat(uri: string): Promise<FileStat> {
4749
const relativePath = this.rootRelative(uri);
4850
const source = this.mockTheme[relativePath];
49-
if (source === undefined) {
50-
throw new Error('File not found');
51+
if (source) {
52+
return {
53+
type: FileType.File,
54+
size: source.length ?? 0,
55+
};
5156
}
52-
return {
53-
type: FileType.File,
54-
size: source.length ?? 0,
55-
};
56-
}
5757

58-
private _fileTree: FileTree | null = null;
58+
const readdirResult = await this.readDirectory(uri);
59+
if (readdirResult) {
60+
return {
61+
type: FileType.Directory,
62+
size: 0, // Size is not applicable for directories
63+
};
64+
}
65+
66+
throw new Error(`File not found: ${uri} for ${this.rootUri}`);
67+
}
5968

6069
private get fileTree(): FileTree {
61-
if (!this._fileTree) {
62-
this._fileTree = {};
63-
for (const [relativePath, source] of Object.entries(this.mockTheme)) {
64-
const segments = relativePath.split('/');
65-
let current = this._fileTree;
66-
for (let i = 0; i < segments.length - 1; i++) {
67-
const segment = segments[i];
68-
current[segment] ??= {};
69-
current = current[segment] as FileTree;
70-
}
71-
current[segments[segments.length - 1]] = source;
70+
const result: FileTree = {};
71+
for (const [relativePath, source] of Object.entries(this.mockTheme)) {
72+
const segments = relativePath.split('/');
73+
let current = result;
74+
for (let i = 0; i < segments.length - 1; i++) {
75+
const segment = segments[i];
76+
current[segment] ??= {};
77+
current = current[segment] as FileTree;
7278
}
79+
current[segments[segments.length - 1]] = source;
7380
}
74-
return this._fileTree;
81+
return result;
7582
}
7683

7784
private rootRelative(uri: string) {

packages/theme-check-common/src/test/test-helper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export async function check(
7575
const isValidSchema = async () => true;
7676

7777
const defaultMockDependencies: Dependencies = {
78-
fs: new MockFileSystem(themeDesc),
78+
fs: new MockFileSystem({ '.theme-check.yml': '', ...themeDesc }),
7979
async getBlockSchema(name) {
8080
const block = blocks.get(name);
8181
if (!block) return undefined;

packages/theme-check-node/src/find-root.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { NodeFileSystem } from './NodeFileSystem';
44
import { makeTempWorkspace, Workspace } from './test/test-helpers';
55

66
const theme = {
7+
assets: {
8+
'theme.js': '',
9+
'theme.css': '',
10+
},
711
locales: {
812
'en.default.json': JSON.stringify({ beverage: 'coffee' }),
913
'fr.json': '{}',

packages/theme-language-server-common/src/diagnostics/runChecks.spec.ts

Lines changed: 54 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
AbstractFileSystem,
23
allChecks,
34
LiquidCheckDefinition,
45
path,
@@ -44,6 +45,9 @@ describe('Module: runChecks', () => {
4445
let documentManager: DocumentManager;
4546
let connection: { sendDiagnostics: ReturnType<typeof vi.fn> };
4647
let runChecks: ReturnType<typeof makeRunChecks>;
48+
let fs: MockFileSystem;
49+
const rootUri = path.normalize('browser:///theme');
50+
const fileUri = path.join(rootUri, 'snippets', 'input.liquid');
4751

4852
beforeEach(() => {
4953
connection = {
@@ -52,13 +56,20 @@ describe('Module: runChecks', () => {
5256

5357
documentManager = new DocumentManager();
5458
diagnosticsManager = new DiagnosticsManager(connection as any as Connection);
59+
fs = new MockFileSystem(
60+
{
61+
'.theme-check.yml': '',
62+
'snippets/input.liquid': `{{ 'any' | filter }}`,
63+
},
64+
rootUri,
65+
);
5566
runChecks = makeRunChecks(documentManager, diagnosticsManager, {
56-
fs: new MockFileSystem({}, 'browser:/'),
67+
fs,
5768
loadConfig: async () => ({
58-
context: 'theme',
69+
context: 'theme' as const,
5970
settings: {},
6071
checks: [LiquidFilter],
61-
rootUri: 'browser:/',
72+
rootUri,
6273
}),
6374
themeDocset: {
6475
filters: async () => [],
@@ -74,15 +85,14 @@ describe('Module: runChecks', () => {
7485
});
7586

7687
it('should send diagnostics when there are errors', async () => {
77-
const fileURI = 'browser:/input.liquid';
78-
const fileContents = `{{ 'any' | filter }}`;
88+
const fileContents = await fs.readFile(fileUri);
7989
const fileVersion = 0;
80-
documentManager.open(fileURI, fileContents, fileVersion);
90+
documentManager.open(fileUri, fileContents, fileVersion);
8191

82-
await runChecks(['browser:/input.liquid']);
92+
await runChecks([fileUri]);
8393
expect(connection.sendDiagnostics).toBeCalled();
8494
expect(connection.sendDiagnostics).toBeCalledWith({
85-
uri: fileURI,
95+
uri: fileUri,
8696
version: fileVersion,
8797
diagnostics: [
8898
{
@@ -106,23 +116,22 @@ describe('Module: runChecks', () => {
106116
});
107117

108118
it('should send an empty array when the errors were cleared', async () => {
109-
const fileURI = 'browser:/input.liquid';
110119
const fileContentsWithError = `{{ 'any' | filter }}`;
111120
const fileContentsWithoutError = `{{ 'any' }}`;
112121
let fileVersion = 1;
113122

114123
// Open and have errors
115-
documentManager.open(fileURI, fileContentsWithError, fileVersion);
116-
await runChecks(['browser:/input.liquid']);
124+
documentManager.open(fileUri, fileContentsWithError, fileVersion);
125+
await runChecks([fileUri]);
117126

118127
// Change doc to fix errors
119128
fileVersion = 2;
120-
documentManager.change(fileURI, fileContentsWithoutError, fileVersion);
121-
await runChecks(['browser:/input.liquid']);
129+
documentManager.change(fileUri, fileContentsWithoutError, fileVersion);
130+
await runChecks([fileUri]);
122131

123132
expect(connection.sendDiagnostics).toBeCalledTimes(2);
124133
expect(connection.sendDiagnostics).toHaveBeenLastCalledWith({
125-
uri: fileURI,
134+
uri: fileUri,
126135
version: fileVersion,
127136
diagnostics: [],
128137
});
@@ -131,7 +140,7 @@ describe('Module: runChecks', () => {
131140
it('should send diagnostics per URI when there are errors', async () => {
132141
const files = [
133142
{
134-
fileURI: 'browser:/input1.liquid',
143+
fileURI: path.join(rootUri, 'snippets', 'input1.liquid'),
135144
fileContents: `{{ 'any' | filter }}`,
136145
fileVersion: 0,
137146
diagnostics: [
@@ -154,7 +163,7 @@ describe('Module: runChecks', () => {
154163
],
155164
},
156165
{
157-
fileURI: 'browser:/input2.liquid',
166+
fileURI: path.join(rootUri, 'snippets', 'input2.liquid'),
158167
// same but on a new line
159168
fileContents: `\n{{ 'any' | filter }}`,
160169
fileVersion: 0,
@@ -183,7 +192,7 @@ describe('Module: runChecks', () => {
183192
documentManager.open(fileURI, fileContents, fileVersion);
184193
});
185194

186-
await runChecks(['browser:/input1.liquid']);
195+
await runChecks([path.join(rootUri, 'snippets', 'input1.liquid')]);
187196

188197
files.forEach(({ fileURI, fileVersion, diagnostics }) => {
189198
expect(connection.sendDiagnostics).toBeCalledWith({
@@ -196,23 +205,24 @@ describe('Module: runChecks', () => {
196205

197206
it('should use the contents of the default translations file buffer (if any) instead of the result of the factory', async () => {
198207
const defaultPath = 'locales/en.default.json';
199-
const defaultURI = `browser:/${defaultPath}`;
208+
const defaultURI = path.join(rootUri, ...defaultPath.split('/'));
200209
const frPath = 'locales/fr.json';
201-
const frURI = `browser:/${frPath}`;
210+
const frURI = path.join(rootUri, ...frPath.split('/'));
202211
const files = {
212+
'.theme-check.yml': '',
203213
[defaultPath]: JSON.stringify({ hello: 'hello' }),
204214
[frPath]: JSON.stringify({ hello: 'bonjour', hi: 'salut' }),
205215
};
206216

207217
const matchingTranslation = allChecks.filter((c) => c.meta.code === 'MatchingTranslations');
208218
expect(matchingTranslation).to.have.lengthOf(1);
209219
runChecks = makeRunChecks(documentManager, diagnosticsManager, {
210-
fs: new MockFileSystem(files, path.normalize('browser:/')),
220+
fs: new MockFileSystem(files, rootUri),
211221
loadConfig: async () => ({
212222
context: 'theme',
213223
settings: {},
214224
checks: matchingTranslation,
215-
rootUri: path.normalize('browser:/'),
225+
rootUri: rootUri,
216226
}),
217227
themeDocset: {
218228
filters: async () => [],
@@ -229,29 +239,31 @@ describe('Module: runChecks', () => {
229239
// Open and have errors
230240
documentManager.open(frURI, files[frPath], 0);
231241
await runChecks([frURI]);
232-
expect(connection.sendDiagnostics).toHaveBeenCalledWith({
233-
uri: frURI,
234-
version: 0,
235-
diagnostics: [
236-
{
237-
source: 'theme-check',
238-
code: 'MatchingTranslations',
239-
codeDescription: { href: expect.any(String) },
240-
message: `A default translation for 'hi' does not exist`,
241-
severity: 1,
242-
range: {
243-
end: {
244-
character: 31,
245-
line: 0,
246-
},
247-
start: {
248-
character: 19,
249-
line: 0,
242+
expect(connection.sendDiagnostics).toHaveBeenCalledWith(
243+
expect.objectContaining({
244+
uri: frURI,
245+
version: 0,
246+
diagnostics: expect.arrayContaining([
247+
{
248+
source: 'theme-check',
249+
code: 'MatchingTranslations',
250+
codeDescription: { href: expect.any(String) },
251+
message: `A default translation for 'hi' does not exist`,
252+
severity: 1,
253+
range: {
254+
end: {
255+
character: 31,
256+
line: 0,
257+
},
258+
start: {
259+
character: 19,
260+
line: 0,
261+
},
250262
},
251263
},
252-
},
253-
],
254-
});
264+
]),
265+
}),
266+
);
255267

256268
// Change the contents of the defaultURI buffer, expect frURI to be fixed
257269
documentManager.open(defaultURI, files[defaultPath], 0);

packages/theme-language-server-common/src/diagnostics/runChecks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export function makeRunChecks(
4343
// then we recheck theme1
4444
const fileExists = makeFileExists(fs);
4545
const rootURIs = await Promise.all(triggerURIs.map((uri) => findRoot(uri, fileExists)));
46-
const deduplicatedRootURIs = new Set(rootURIs);
46+
const deduplicatedRootURIs = new Set<string>(rootURIs.filter((x): x is string => !!x));
4747
await Promise.all([...deduplicatedRootURIs].map(runChecksForRoot));
4848

4949
return;

0 commit comments

Comments
 (0)