Skip to content
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
15 changes: 8 additions & 7 deletions packages/platformos-check-common/src/checks/translation-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ export async function discoverModules(
): Promise<Set<string>> {
const modules = new Set<string>();
for (const dirUri of moduleDirUris) {
const stat = await fs.stat(dirUri).catch(() => undefined);
if (!stat || stat.type !== FileType.Directory) continue;

const entries = await fs.readDirectory(dirUri);
for (const [entryUri, entryType] of entries) {
if (entryType === FileType.Directory) {
modules.add(entryUri.split('/').pop()!);
try {
const entries = await fs.readDirectory(dirUri);
for (const [entryUri, entryType] of entries) {
if (entryType === FileType.Directory) {
modules.add(entryUri.split('/').pop()!);
}
}
} catch {
// Directory doesn't exist or isn't accessible — skip
}
}
return modules;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,8 @@ export class DocumentsLocator {
}

/**
* Returns the canonical default URI for a missing file so it can be created.
* Returns the canonical URI where `fileName` would live — used as a
* go-to-definition fallback when the file doesn't exist yet.
* Returns undefined for theme_render_rc (ambiguous search path) and asset.
*/
locateDefault(rootUri: URI, nodeName: DocumentType, fileName: string): string | undefined {
Expand All @@ -289,13 +290,32 @@ export class DocumentsLocator {
basePath = parsed.isModule ? `modules/${parsed.moduleName}/public/graphql` : 'app/graphql';
ext = '.graphql';
break;
case 'theme_render_rc': // ambiguous — multiple search paths, no single canonical location
case 'asset': // no canonical creation path
return undefined;
default:
return undefined;
}

return Utils.joinPath(rootUri, basePath, parsed.key + ext).toString();
}

/**
* Resolves `fileName` to a filesystem URI (if the file exists), or falls
* back to the canonical default URI from `locateDefault`.
*/
async locateOrDefault(
rootUri: URI,
nodeName: DocumentType,
fileName: string,
themeSearchPaths?: string[] | null,
): Promise<string | undefined> {
return (
(await this.locate(rootUri, nodeName, fileName, themeSearchPaths)) ??
this.locateDefault(rootUri, nodeName, fileName)
);
}

async locate(
rootUri: URI,
nodeName: DocumentType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,12 @@ export class RenderPartialDefinitionProvider implements BaseDefinitionProvider {
const root = URI.parse(rootUri);
const searchPaths = await this.searchPathsCache.get(root);
const docType = (tag as LiquidTag).name as DocumentType;
const fileUri =
(await this.documentsLocator.locate(
root,
docType,
(node as LiquidString).value,
searchPaths,
)) ?? this.documentsLocator.locateDefault(root, docType, (node as LiquidString).value);
const fileUri = await this.documentsLocator.locateOrDefault(
root,
docType,
(node as LiquidString).value,
searchPaths,
);
if (!fileUri) return [];

const sourceCode = this.documentManager.get(params.textDocument.uri);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,18 @@ function documentLinksVisitor(

// render, include, function, theme_render_rc all have a .partial field
if ('partial' in markup && isLiquidString(markup.partial)) {
const uri =
(await documentsLocator.locate(root, name, markup.partial.value, searchPaths)) ??
documentsLocator.locateDefault(root, name, markup.partial.value);
const uri = await documentsLocator.locateOrDefault(
root,
name,
markup.partial.value,
searchPaths,
);
return DocumentLink.create(range(textDocument, markup.partial), uri);
}

// graphql has a .graphql field
if ('graphql' in markup && isLiquidString(markup.graphql)) {
const uri =
(await documentsLocator.locate(root, name, markup.graphql.value)) ??
documentsLocator.locateDefault(root, name, markup.graphql.value);
const uri = await documentsLocator.locateOrDefault(root, name, markup.graphql.value);
return DocumentLink.create(range(textDocument, markup.graphql), uri);
}
},
Expand Down
6 changes: 6 additions & 0 deletions packages/prettier-plugin-liquid/src/test/test-setup.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const moduleAlias = require('module-alias');
const path = require('path');
const fs = require('fs');

const prettierMajor = process.env.PRETTIER_MAJOR;
const prettierPath =
Expand All @@ -8,6 +9,11 @@ const prettierPath =
: path.join(__dirname, '..', '..', '..', '..', 'node_modules', 'prettier2');

export function setup() {
// Generate the Ohm grammar shim once, before any test workers start.
// This avoids a race condition when parallel workers all try to write the
// same file simultaneously (causes SyntaxError: Unexpected end of input on Windows).
require(path.join(__dirname, '..', '..', '..', 'liquid-html-parser', 'build', 'shims.js'));

moduleAlias.addAlias('prettier', prettierPath);
console.error('====================================');
console.error(`Prettier version: ${require('prettier').version}`);
Expand Down
1 change: 0 additions & 1 deletion packages/prettier-plugin-liquid/vitest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,5 @@ export default defineConfig({
},
},
globalSetup: ['./src/test/test-setup.js'],
setupFiles: ['../liquid-html-parser/build/shims.js'],
},
});
5 changes: 2 additions & 3 deletions packages/vscode-extension/src/browser/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import LiquidFormatter from '../common/formatter';
import { vscodePrettierFormat } from './formatter';
import { documentSelectors } from '../common/constants';
import { openLocation } from '../common/commands';
import { buildMiddleware, openFileMissingCommand } from '../common/middleware';
import { middleware } from '../common/middleware';
import {
createReferencesTreeView,
setupContext,
Expand All @@ -31,7 +31,6 @@ export async function activate(context: ExtensionContext) {
client!.sendRequest('workspace/executeCommand', { command: runChecksCommand });
}),
commands.registerCommand('platformosLiquid.openLocation', openLocation),
commands.registerCommand('platformosLiquid.openFile', openFileMissingCommand),
languages.registerDocumentFormattingEditProvider(
[{ language: 'liquid' }],
new LiquidFormatter(vscodePrettierFormat),
Expand All @@ -58,7 +57,7 @@ async function startServer(context: ExtensionContext) {
console.log('Starting App Check Language Server');
const clientOptions: LanguageClientOptions = {
documentSelector: documentSelectors as DocumentSelector,
middleware: buildMiddleware(),
middleware,
};

client = createWorkerLanguageClient(context, clientOptions);
Expand Down
186 changes: 47 additions & 139 deletions packages/vscode-extension/src/common/middleware.spec.ts
Original file line number Diff line number Diff line change
@@ -1,174 +1,82 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const { mockShowTextDocument, mockStat, MockDocumentLink } = vi.hoisted(() => {
const { mockStat, MockDocumentLink } = vi.hoisted(() => {
class MockDocumentLink {
constructor(
public range: any,
public target?: any,
) {}
}
return {
mockShowTextDocument: vi.fn(),
mockStat: vi.fn(),
MockDocumentLink,
};
return { mockStat: vi.fn(), MockDocumentLink };
});

vi.mock('vscode', () => ({
DocumentLink: MockDocumentLink,
Uri: {
parse: (str: string) => ({ toString: () => str, _str: str }),
},
window: { showTextDocument: mockShowTextDocument },
workspace: { fs: { stat: mockStat } },
}));

import { buildMiddleware, openFileMissingCommand } from './middleware';
import { middleware } from './middleware';

const EXISTING_URI = { toString: () => 'file:///project/app/views/partials/exists.liquid' };
const MISSING_URI = { toString: () => 'file:///project/app/views/partials/missing.liquid' };

const fakeRange = { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } };
const fakeDocument = {} as any;
const fakePosition = {} as any;
const fakeToken = {} as any;

function makeLink(target: any) {
return new MockDocumentLink(fakeRange, target) as any;
}

describe('middleware', () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe('openFileMissingCommand', () => {
it('calls window.showTextDocument with a parsed URI', () => {
openFileMissingCommand('file:///project/app/views/partials/new.liquid');
expect(mockShowTextDocument).toHaveBeenCalledOnce();
expect(mockShowTextDocument.mock.calls[0][0].toString()).to.equal(
'file:///project/app/views/partials/new.liquid',
);
});
});

describe('provideDocumentLinks', () => {
const { provideDocumentLinks } = buildMiddleware();

it('returns null when next returns null', async () => {
const next = vi.fn().mockResolvedValue(null);
const result = await provideDocumentLinks!(fakeDocument, fakeToken, next);
expect(result).to.equal(null);
});

it('returns links unchanged when all targets exist', async () => {
mockStat.mockResolvedValue({});
const link = makeLink(EXISTING_URI);
const next = vi.fn().mockResolvedValue([link]);

const result = await provideDocumentLinks!(fakeDocument, fakeToken, next);

expect(result).to.deep.equal([link]);
expect(mockStat).toHaveBeenCalledWith(EXISTING_URI);
});

it('replaces target with command URI when file is missing', async () => {
mockStat.mockRejectedValue(new Error('file not found'));
const link = makeLink(MISSING_URI);
const next = vi.fn().mockResolvedValue([link]);

const result = (await provideDocumentLinks!(fakeDocument, fakeToken, next)) as any[];

expect(result).toHaveLength(1);
const commandArg = encodeURIComponent(JSON.stringify([MISSING_URI.toString()]));
expect(result[0].target.toString()).to.equal(
`command:platformosLiquid.openFile?${commandArg}`,
);
expect(result[0].range).to.equal(fakeRange);
});

it('handles mixed links: existing unchanged, missing gets command URI', async () => {
mockStat
.mockResolvedValueOnce({}) // first link exists
.mockRejectedValueOnce(new Error('not found')); // second link missing

const existingLink = makeLink(EXISTING_URI);
const missingLink = makeLink(MISSING_URI);
const next = vi.fn().mockResolvedValue([existingLink, missingLink]);

const result = (await provideDocumentLinks!(fakeDocument, fakeToken, next)) as any[];

expect(result).toHaveLength(2);
expect(result[0]).to.equal(existingLink);
expect(result[1].target.toString()).to.include('command:platformosLiquid.openFile');
});
describe('provideDocumentLinks middleware', () => {
const { provideDocumentLinks } = middleware;

it('passes through links with no target unchanged', async () => {
const link = makeLink(undefined);
const next = vi.fn().mockResolvedValue([link]);
beforeEach(() => vi.clearAllMocks());

const result = await provideDocumentLinks!(fakeDocument, fakeToken, next);

expect(result).to.deep.equal([link]);
expect(mockStat).not.toHaveBeenCalled();
});
it('returns null when next returns null', async () => {
const next = vi.fn().mockResolvedValue(null);
expect(await provideDocumentLinks!(fakeDocument, fakeToken, next)).to.equal(null);
});

describe('provideDefinition', () => {
const { provideDefinition } = buildMiddleware();

it('returns null when next returns null', async () => {
const next = vi.fn().mockResolvedValue(null);
const result = await provideDefinition!(fakeDocument, fakePosition, fakeToken, next);
expect(result).to.equal(null);
});

it('returns result unchanged when target file exists (Location shape)', async () => {
mockStat.mockResolvedValue({});
const location = { uri: EXISTING_URI };
const next = vi.fn().mockResolvedValue(location);

const result = await provideDefinition!(fakeDocument, fakePosition, fakeToken, next);

expect(result).to.equal(location);
expect(mockShowTextDocument).not.toHaveBeenCalled();
});

it('returns result unchanged when target file exists (LocationLink shape)', async () => {
mockStat.mockResolvedValue({});
const locationLink = {
targetUri: EXISTING_URI,
targetRange: fakeRange,
originSelectionRange: fakeRange,
};
const next = vi.fn().mockResolvedValue([locationLink]);

const result = await provideDefinition!(fakeDocument, fakePosition, fakeToken, next);

expect(result).to.deep.equal([locationLink]);
expect(mockShowTextDocument).not.toHaveBeenCalled();
});

it('opens missing file and returns null (Location shape)', async () => {
mockStat.mockRejectedValue(new Error('not found'));
const location = { uri: MISSING_URI };
const next = vi.fn().mockResolvedValue(location);

const result = await provideDefinition!(fakeDocument, fakePosition, fakeToken, next);

expect(result).to.equal(null);
expect(mockShowTextDocument).toHaveBeenCalledWith(MISSING_URI);
});
it('returns links unchanged when all targets exist', async () => {
mockStat.mockResolvedValue({});
const link = makeLink(EXISTING_URI);
const result = await provideDocumentLinks!(
fakeDocument,
fakeToken,
vi.fn().mockResolvedValue([link]),
);
expect(result).to.deep.equal([link]);
});

it('opens missing file and returns null (LocationLink shape)', async () => {
mockStat.mockRejectedValue(new Error('not found'));
const locationLink = { targetUri: MISSING_URI, targetRange: fakeRange };
const next = vi.fn().mockResolvedValue([locationLink]);
it('removes link when target file is missing — ctrl+click falls through to go-to-definition', async () => {
mockStat.mockRejectedValue(new Error('file not found'));
const result = await provideDocumentLinks!(
fakeDocument,
fakeToken,
vi.fn().mockResolvedValue([makeLink(MISSING_URI)]),
);
expect(result).to.deep.equal([]);
});

const result = await provideDefinition!(fakeDocument, fakePosition, fakeToken, next);
it('keeps existing links, removes missing ones', async () => {
mockStat.mockResolvedValueOnce({}).mockRejectedValueOnce(new Error('not found'));
const existingLink = makeLink(EXISTING_URI);
const result = (await provideDocumentLinks!(
fakeDocument,
fakeToken,
vi.fn().mockResolvedValue([existingLink, makeLink(MISSING_URI)]),
)) as any[];
expect(result).to.deep.equal([existingLink]);
});

expect(result).to.equal(null);
expect(mockShowTextDocument).toHaveBeenCalledWith(MISSING_URI);
});
it('passes through links with no target unchanged', async () => {
const link = makeLink(undefined);
const result = await provideDocumentLinks!(
fakeDocument,
fakeToken,
vi.fn().mockResolvedValue([link]),
);
expect(result).to.deep.equal([link]);
expect(mockStat).not.toHaveBeenCalled();
});
});
Loading