diff --git a/src/project-roots.ts b/src/project-roots.ts index 32cc4518..a4b46ba8 100644 --- a/src/project-roots.ts +++ b/src/project-roots.ts @@ -18,6 +18,7 @@ export default class ProjectRoots { projects = new Map(); localAddons: string[] = []; + ignoredProjects: string[] = []; reloadProjects() { Array.from(this.projects).forEach(([root]) => { @@ -52,6 +53,10 @@ export default class ProjectRoots { }); } + setIgnoredProjects(ignoredProjects: string[]) { + this.ignoredProjects = ignoredProjects; + } + findProjectsInsideRoot(workspaceRoot: string) { const roots = walkSync(workspaceRoot, { directories: false, @@ -138,16 +143,24 @@ export default class ProjectRoots { */ const rootMap: { [key: string]: string } = {}; - const projectRoots = (Array.from(this.projects.keys()) || []).map((root) => { - const lowerName = root.toLowerCase(); + const projectRoots = (Array.from(this.projects.keys()) || []) + .map((root) => { + const projectName = this.projects.get(root)?.name; + + if (projectName && this.ignoredProjects.includes(projectName)) { + return; + } - rootMap[lowerName] = root; + const lowerName = root.toLowerCase(); - return lowerName; - }); + rootMap[lowerName] = root; + + return lowerName; + }) + .filter((item) => item !== undefined) as string[]; const rawRoot = projectRoots - .filter((root) => isRootStartingWithFilePath(filePath, root)) + .filter((root) => isRootStartingWithFilePath(root, filePath)) .reduce((a, b) => { return a.length > b.length ? a : b; }, ''); @@ -167,9 +180,9 @@ export default class ProjectRoots { ==================== it's safe to do, because root will be non empty if addon already registered as Project */ - const fistSubRoot = Array.from(this.projects.values()).find((project) => { - return project.roots.some((subRoot) => isRootStartingWithFilePath(subRoot.toLocaleLowerCase(), filePath)); - }); + const fistSubRoot = Array.from(this.projects.values()) + .filter((project) => project.name && !this.ignoredProjects.includes(project.name)) + .find((project) => project.roots.some((subRoot) => isRootStartingWithFilePath(subRoot.toLocaleLowerCase(), filePath))); if (fistSubRoot) { return fistSubRoot; diff --git a/src/server.ts b/src/server.ts index 21ec9879..e096640f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -136,8 +136,9 @@ export default class Server { this.connection.workspace.onDidChangeWorkspaceFolders(this.onDidChangeWorkspaceFolders.bind(this)); } - this.executors['els.setConfig'] = async (_, __, [config]: [{ local: { addons: string[] } }]) => { + this.executors['els.setConfig'] = async (_, __, [config]: [{ local: { addons: string[]; ignoredProjects: string[] } }]) => { this.projectRoots.setLocalAddons(config.local.addons); + this.projectRoots.setIgnoredProjects(config.local.ignoredProjects); if (this.lazyInit) { this.executeInitializers(); diff --git a/test/integration-test.ts b/test/integration-test.ts index 33270d47..2f7c83fc 100644 --- a/test/integration-test.ts +++ b/test/integration-test.ts @@ -1461,7 +1461,7 @@ describe('integration', function () { dependencies: { 'ember-holy-futuristic-template-namespacing-batman': '^1.0.2' }, }), }, - 'lib/biz/addon/templates/components/bar.hbs', + 'full-project/lib/biz/addon/templates/components/bar.hbs', { line: 0, character: 2 }, 'full-project' ); @@ -1577,6 +1577,111 @@ describe('integration', function () { expect(result.response).toMatchSnapshot(); }); + describe('Project class resolution, based on fs path and file structure', () => { + it('able to resolve main project if top-level addon is registered', async () => { + const files = { + 'full-project/app/components': { + 'foo.hbs': '', + 'bar.hbs': '', + }, + 'full-project/package.json': JSON.stringify({ + name: 'full-project', + 'ember-addon': { + paths: ['../lib'], + }, + }), + lib: { + 'package.json': JSON.stringify({ + name: 'my-addon', + keywords: ['ember-addon'], + }), + 'index.js': '', + 'addon/components/item.hbs': '<', + }, + }; + + const result = await getResult(CompletionRequest.method, connection, files, 'lib/addon/components/item.hbs', { line: 0, character: 1 }, 'full-project'); + + expect(result.response.length).toBe(3); + }); + + it('without ignoring main project returns one only top level result', async () => { + const files = { + 'child-project/app/components': { + 'foo.hbs': '', + 'bar.hbs': '', + }, + 'child-project/package.json': JSON.stringify({ + name: 'child-project', + 'ember-addon': { + paths: ['../lib'], + }, + }), + lib: { + 'package.json': JSON.stringify({ + name: 'my-addon', + keywords: ['ember-addon'], + }), + 'index.js': '', + 'addon/components/item.hbs': '<', + }, + 'package.json': JSON.stringify({ + name: 'parent-project', + 'ember-addon': { + paths: ['lib'], + }, + }), + }; + + const result = await getResult(CompletionRequest.method, connection, files, 'lib/addon/components/item.hbs', { line: 0, character: 1 }, [ + '', + 'child-project', + ]); + + expect(result.length).toBe(2); + expect(result[0].response.length).toBe(1); + }); + + it('able to ignore main project in favor of child project', async () => { + const files = { + 'child-project/app/components': { + 'foo.hbs': '', + 'bar.hbs': '', + }, + 'child-project/package.json': JSON.stringify({ + name: 'child-project', + 'ember-addon': { + paths: ['../lib'], + }, + }), + lib: { + 'package.json': JSON.stringify({ + name: 'my-addon', + keywords: ['ember-addon'], + }), + 'index.js': '', + 'addon/components/item.hbs': '<', + }, + 'package.json': JSON.stringify({ + name: 'parent-project', + }), + }; + + const result = await getResult( + CompletionRequest.method, + connection, + files, + 'lib/addon/components/item.hbs', + { line: 0, character: 1 }, + ['', 'child-project'], + { local: { addons: [], ignoredProjects: ['parent-project'] } } + ); + + expect(result.length).toBe(2); + expect(result[0].response.length).toBe(3); + }); + }); + describe('Autocomplete works for broken templates', () => { it('autocomplete information for component #1 {{', async () => { const result = await getResult( diff --git a/test/test_helpers/integration-helpers-test.ts b/test/test_helpers/integration-helpers-test.ts index 74687e0c..a47e8d80 100644 --- a/test/test_helpers/integration-helpers-test.ts +++ b/test/test_helpers/integration-helpers-test.ts @@ -42,6 +42,30 @@ describe('normalizeToFs', () => { expect(normalizeToFs(files)).toStrictEqual(expectedObj); }); + + it('support corner case', () => { + const files = { + 'full-project/app/components': { + 'foo.hbs': '', + 'bar.hbs': '', + }, + 'full-project/package.json': '', + }; + + const expectedObj = { + 'full-project': { + 'package.json': '', + app: { + components: { + 'foo.hbs': '', + 'bar.hbs': '', + }, + }, + }, + }; + + expect(normalizeToFs(files)).toStrictEqual(expectedObj); + }); }); describe('flattenFsProject', () => { diff --git a/test/test_helpers/integration-helpers.ts b/test/test_helpers/integration-helpers.ts index 61cb2535..f12cf919 100644 --- a/test/test_helpers/integration-helpers.ts +++ b/test/test_helpers/integration-helpers.ts @@ -5,7 +5,7 @@ import { createTempDir } from 'broccoli-test-helper'; import { URI } from 'vscode-uri'; import { MessageConnection } from 'vscode-jsonrpc/node'; import * as spawn from 'cross-spawn'; -import { set } from 'lodash'; +import { set, merge, get } from 'lodash'; import { DidOpenTextDocumentNotification, @@ -124,46 +124,103 @@ export function normalizeToFs(files: RecursiveRecord { + const isLast = index === parts.length - 1; + + if (!(p in entry)) { + entry[p] = {}; + } + + if (isLast) { + merge(entry[p], value); + } else { + entry = entry[p]; + } + }); } }); return newShape; } +async function _buildResult(connection: MessageConnection, normalizedPath: string) { + const params = { + command: 'els.registerProjectPath', + arguments: [normalizedPath], + }; + + return (await connection.sendRequest(ExecuteCommandRequest.type, params)) as { + registry: Registry; + }; +} + export async function createProject( files, connection: MessageConnection, - projectName?: string -): Promise<{ normalizedPath: string; result: UnknownResult; destroy(): void }> { + projectName?: string | string[], + config?: { local: { addons: string[]; ignoredProjects: string[] } } +): Promise<{ normalizedPath: string | string[]; originalPath: string; result: UnknownResult | UnknownResult[]; destroy(): void }> { const dir = await createTempDir(); dir.write(normalizeToFs(files)); - const normalizedPath = projectName ? path.normalize(path.join(dir.path(), projectName)) : path.normalize(dir.path()); - const params = { - command: 'els.registerProjectPath', - arguments: [normalizedPath], - }; + if (config && Array.isArray(config.local.ignoredProjects)) { + const ignoredProjects = config.local.ignoredProjects; - const result = (await connection.sendRequest(ExecuteCommandRequest.type, params)) as { - registry: Registry; - }; + const configParams = { + command: 'els.setConfig', + arguments: [{ local: { addons: [], ignoredProjects: ignoredProjects } }], + }; - return { - normalizedPath, - result, - destroy: async () => { - await dir.dispose(); - }, - }; + await connection.sendRequest(ExecuteCommandRequest.type, configParams); + } + + if (Array.isArray(projectName)) { + const resultsArr = []; + const normalizedPaths = []; + + for (let i = 0; i < projectName.length; i++) { + const currProject = projectName[i]; + const normalizedPath = currProject ? path.normalize(path.join(dir.path(), currProject)) : path.normalize(dir.path()); + + normalizedPaths.push(normalizedPath); + resultsArr.push(await _buildResult(connection, normalizedPath)); + } + + return { + normalizedPath: normalizedPaths, + originalPath: path.normalize(dir.path()), + result: resultsArr, + destroy: async () => { + await dir.dispose(); + }, + }; + } else { + const normalizedPath = projectName ? path.normalize(path.join(dir.path(), projectName)) : path.normalize(dir.path()); + const result = await _buildResult(connection, normalizedPath); + + return { + normalizedPath, + originalPath: path.normalize(dir.path()), + result, + destroy: async () => { + await dir.dispose(); + }, + }; + } } export function textDocument(modelPath, position = { line: 0, character: 0 }) { @@ -177,9 +234,30 @@ export function textDocument(modelPath, position = { line: 0, character: 0 }) { return params; } -export async function getResult(reqType, connection: MessageConnection, files, fileToInspect, position, projectName?: string) { - const { normalizedPath, destroy, result } = await createProject(files, connection, projectName); - const modelPath = path.join(normalizedPath, fileToInspect); +function _buildResponse(response: unknown, normalizedPath: string, result: UnknownResult) { + return { + response: normalizeUri(response, normalizedPath), + registry: normalizeRegistry(normalizedPath, result.registry as Registry), + addonsMeta: normalizeAddonsMeta(normalizedPath, result.addonsMeta as { name: string; root: string }[]), + }; +} + +export async function getResult( + reqType, + connection: MessageConnection, + files, + fileToInspect, + position, + projectName?: string | string[], + config?: { local: { addons: string[]; ignoredProjects: string[] } } +) { + const { normalizedPath, originalPath, destroy, result } = await createProject(files, connection, projectName, config); + const modelPath = path.join(originalPath, fileToInspect); + + if (!fs.existsSync(modelPath)) { + throw new Error(`Unabe to find file path to inspect in file system. - ${fileToInspect}`); + } + const params = textDocument(modelPath, position); openFile(connection, modelPath); @@ -187,11 +265,17 @@ export async function getResult(reqType, connection: MessageConnection, files, f await destroy(); - return { - response: normalizeUri(response, normalizedPath), - registry: normalizeRegistry(normalizedPath, result.registry as Registry), - addonsMeta: normalizeAddonsMeta(normalizedPath, result.addonsMeta as { name: string; root: string }[]), - }; + if (Array.isArray(projectName)) { + const resultsArr = []; + + for (let i = 0; i < projectName.length; i++) { + resultsArr.push(_buildResponse(response, normalizedPath[i], result[i])); + } + + return resultsArr; + } + + return _buildResponse(response, normalizedPath as string, result as UnknownResult); } export function openFile(connection: MessageConnection, filePath: string) {