Skip to content

Commit 527e107

Browse files
authored
feat(vscode): multi project for vscode (#5084)
1 parent 622a36f commit 527e107

File tree

10 files changed

+147
-38
lines changed

10 files changed

+147
-38
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,4 +157,4 @@ metastore_db/
157157
spark-warehouse/
158158

159159
# claude
160-
.claude/
160+
.claude/

examples/multi/.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"sqlmesh.projectPaths": ["./repo_1", "./repo_2"]
3+
}

sqlmesh/lsp/main.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,20 @@
7171
from sqlmesh.lsp.uri import URI
7272
from sqlmesh.utils.errors import ConfigError
7373
from sqlmesh.utils.lineage import ExternalModelReference
74+
from sqlmesh.utils.pydantic import PydanticModel
7475
from web.server.api.endpoints.lineage import column_lineage, model_lineage
7576
from web.server.api.endpoints.models import get_models
7677
from typing import Union
7778
from dataclasses import dataclass, field
7879

7980

81+
class InitializationOptions(PydanticModel):
82+
"""Initialization options for the SQLMesh Language Server, that
83+
are passed from the client to the server."""
84+
85+
project_paths: t.Optional[t.List[str]] = None
86+
87+
8088
@dataclass
8189
class NoContext:
8290
"""State when no context has been attempted to load."""
@@ -105,6 +113,11 @@ class ContextFailed:
105113

106114

107115
class SQLMeshLanguageServer:
116+
# Specified folders take precedence over workspace folders or looking
117+
# for a config files. They are explicitly set by the user and optionally
118+
# pass in at init
119+
specified_paths: t.Optional[t.List[Path]] = None
120+
108121
def __init__(
109122
self,
110123
context_class: t.Type[Context],
@@ -411,6 +424,12 @@ def command_external_models_update_columns(ls: LanguageServer, raw: t.Any) -> No
411424
def initialize(ls: LanguageServer, params: types.InitializeParams) -> None:
412425
"""Initialize the server when the client connects."""
413426
try:
427+
# Check the custom options
428+
if params.initialization_options:
429+
options = InitializationOptions.model_validate(params.initialization_options)
430+
if options.project_paths is not None:
431+
self.specified_paths = [Path(path) for path in options.project_paths]
432+
414433
# Check if the client supports pull diagnostics
415434
if params.capabilities and params.capabilities.text_document:
416435
diagnostics = getattr(params.capabilities.text_document, "diagnostic", None)
@@ -906,7 +925,12 @@ def _context_get_or_load(self, document_uri: t.Optional[URI] = None) -> LSPConte
906925
raise Exception(state.error)
907926
raise state.error
908927
if isinstance(state, NoContext):
909-
self._ensure_context_for_document(document_uri)
928+
if self.specified_paths is not None:
929+
# If specified paths are provided, create context from them
930+
if self._create_lsp_context(self.specified_paths):
931+
loaded_sqlmesh_message(self.server)
932+
else:
933+
self._ensure_context_for_document(document_uri)
910934
if isinstance(state, ContextLoaded):
911935
return state.lsp_context
912936
raise RuntimeError("Context failed to load")

vscode/extension/package.json

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,13 @@
3232
"type": "object",
3333
"title": "SQLMesh",
3434
"properties": {
35-
"sqlmesh.projectPath": {
36-
"type": "string",
37-
"default": "",
38-
"markdownDescription": "The path to the SQLMesh project. If not set, the extension will try to find the project root automatically. If set, the extension will use the project root as the workspace path, e.g. it will run `sqlmesh` and `sqlmesh_lsp` in the project root. The path can be absolute `/Users/sqlmesh_user/sqlmesh_project/sushi` or relative `./project_folder/sushi` to the workspace root."
35+
"sqlmesh.projectPaths": {
36+
"type": "array",
37+
"items": {
38+
"type": "string"
39+
},
40+
"default": [],
41+
"description": "The path to the SQLMesh project. If not set, the extension will try to find the project root automatically. If set, the extension will use the project root as the workspace path, e.g. it will run `sqlmesh` and `sqlmesh_lsp` in the project root. The path can be absolute `/Users/sqlmesh_user/sqlmesh_project/sushi` or relative `./project_folder/sushi` to the workspace root. Multiple paths can be used for multi-project setups."
3942
},
4043
"sqlmesh.lspEntrypoint": {
4144
"type": "string",

vscode/extension/src/lsp/lsp.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
ErrorTypeSQLMeshOutdated,
1717
} from '../utilities/errors'
1818
import { CustomLSPMethods } from './custom'
19+
import { resolveProjectPath } from '../utilities/config'
1920

2021
type SupportedMethodsState =
2122
| { type: 'not-fetched' }
@@ -109,6 +110,11 @@ export class LSPClient implements Disposable {
109110
args: sqlmesh.value.args,
110111
},
111112
}
113+
const paths = resolveProjectPath(getWorkspaceFolders()[0])
114+
if (isErr(paths)) {
115+
traceError(`Failed to resolve project paths: ${paths.error}`)
116+
return err({ type: 'generic', message: paths.error })
117+
}
112118
const clientOptions: LanguageClientOptions = {
113119
documentSelector: [
114120
{ scheme: 'file', pattern: '**/*.sql' },
@@ -117,6 +123,11 @@ export class LSPClient implements Disposable {
117123
],
118124
diagnosticCollectionName: 'sqlmesh',
119125
outputChannel,
126+
initializationOptions: paths.value.projectPaths
127+
? {
128+
project_paths: paths.value.projectPaths,
129+
}
130+
: null,
120131
}
121132

122133
traceInfo(

vscode/extension/src/utilities/config.ts

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import { workspace, WorkspaceFolder } from 'vscode'
22
import path from 'path'
33
import fs from 'fs'
4-
import { Result, err, ok } from '@bus/result'
4+
import { Result, err, isErr, ok } from '@bus/result'
55
import { traceVerbose, traceInfo } from './common/log'
66
import { parse } from 'shell-quote'
77
import { z } from 'zod'
88

9-
const configSchema = z.object({
10-
projectPath: z.string(),
9+
const sqlmeshConfigurationSchema = z.object({
10+
projectPaths: z.array(z.string()),
1111
lspEntryPoint: z.string(),
1212
})
1313

14-
export type SqlmeshConfiguration = z.infer<typeof configSchema>
14+
export type SqlmeshConfiguration = z.infer<typeof sqlmeshConfigurationSchema>
1515

1616
/**
1717
* Get the SQLMesh configuration from VS Code settings.
@@ -20,16 +20,15 @@ export type SqlmeshConfiguration = z.infer<typeof configSchema>
2020
*/
2121
function getSqlmeshConfiguration(): SqlmeshConfiguration {
2222
const config = workspace.getConfiguration('sqlmesh')
23-
const projectPath = config.get<string>('projectPath', '')
23+
const projectPaths = config.get<string[]>('projectPaths', [])
2424
const lspEntryPoint = config.get<string>('lspEntrypoint', '')
25-
26-
const parsed = configSchema.safeParse({
27-
projectPath,
25+
const parsed = sqlmeshConfigurationSchema.safeParse({
26+
projectPaths,
2827
lspEntryPoint,
2928
})
3029
if (!parsed.success) {
3130
throw new Error(
32-
`Invalid sqlmesh configuration: ${JSON.stringify(parsed.error)}`,
31+
`Invalid SQLMesh configuration: ${JSON.stringify(parsed.error)}`,
3332
)
3433
}
3534
return parsed.data
@@ -66,31 +65,57 @@ export function getSqlmeshLspEntryPoint():
6665
}
6766

6867
/**
69-
* Validate and resolve the project path from configuration.
68+
* Validate and resolve the project paths from configuration.
7069
* If no project path is configured, use the workspace folder.
7170
* If the project path is configured, it must be a directory that contains a SQLMesh project.
7271
*
7372
* @param workspaceFolder The current workspace folder
74-
* @returns A Result containing the resolved project path or an error
73+
* @returns A Result containing the resolved project paths or an error
7574
*/
76-
export function resolveProjectPath(
77-
workspaceFolder: WorkspaceFolder,
78-
): Result<string, string> {
75+
export function resolveProjectPath(workspaceFolder: WorkspaceFolder): Result<
76+
{
77+
projectPaths: string[] | undefined
78+
workspaceFolder: string
79+
},
80+
string
81+
> {
7982
const config = getSqlmeshConfiguration()
8083

81-
if (!config.projectPath) {
84+
if (config.projectPaths.length === 0) {
8285
// If no project path is configured, use the workspace folder
8386
traceVerbose('No project path configured, using workspace folder')
84-
return ok(workspaceFolder.uri.fsPath)
87+
return ok({
88+
workspaceFolder: workspaceFolder.uri.fsPath,
89+
projectPaths: undefined,
90+
})
91+
}
92+
93+
const resolvedPaths: string[] = []
94+
for (const projectPath of config.projectPaths) {
95+
const result = resolveSingleProjectPath(workspaceFolder, projectPath)
96+
if (isErr(result)) {
97+
return result
98+
}
99+
resolvedPaths.push(result.value)
85100
}
101+
return ok({
102+
projectPaths: resolvedPaths,
103+
workspaceFolder: workspaceFolder.uri.fsPath,
104+
})
105+
}
106+
107+
function resolveSingleProjectPath(
108+
workspaceFolder: WorkspaceFolder,
109+
projectPath: string,
110+
): Result<string, string> {
86111
let resolvedPath: string
87112

88113
// Check if the path is absolute
89-
if (path.isAbsolute(config.projectPath)) {
90-
resolvedPath = config.projectPath
114+
if (path.isAbsolute(projectPath)) {
115+
resolvedPath = projectPath
91116
} else {
92117
// Resolve relative path from workspace root
93-
resolvedPath = path.join(workspaceFolder.uri.fsPath, config.projectPath)
118+
resolvedPath = path.join(workspaceFolder.uri.fsPath, projectPath)
94119
}
95120

96121
// Normalize the path

vscode/extension/src/utilities/sqlmesh/sqlmesh.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ export const isTcloudProject = async (): Promise<Result<boolean, string>> => {
6969
if (isErr(resolvedPath)) {
7070
return err(resolvedPath.error)
7171
}
72-
const tcloudYamlPath = path.join(resolvedPath.value, 'tcloud.yaml')
73-
const tcloudYmlPath = path.join(resolvedPath.value, 'tcloud.yml')
72+
const tcloudYamlPath = path.join(resolvedPath.value.workspaceFolder, 'tcloud.yaml')
73+
const tcloudYmlPath = path.join(resolvedPath.value.workspaceFolder, 'tcloud.yml')
7474
const isTcloudYamlFilePresent = fs.existsSync(tcloudYamlPath)
7575
const isTcloudYmlFilePresent = fs.existsSync(tcloudYmlPath)
7676
if (isTcloudYamlFilePresent || isTcloudYmlFilePresent) {
@@ -144,7 +144,7 @@ export const isSqlmeshEnterpriseInstalled = async (): Promise<
144144
})
145145
}
146146
const called = await execAsync(tcloudBin.value.bin, ['is_sqlmesh_installed'], {
147-
cwd: resolvedPath.value,
147+
cwd: resolvedPath.value.workspaceFolder,
148148
env: tcloudBin.value.env,
149149
})
150150
if (called.exitCode !== 0) {
@@ -185,7 +185,7 @@ export const installSqlmeshEnterprise = async (
185185
}
186186
const called = await execAsync(tcloudBin.value.bin, ['install_sqlmesh'], {
187187
signal: abortController.signal,
188-
cwd: resolvedPath.value,
188+
cwd: resolvedPath.value.workspaceFolder,
189189
env: tcloudBin.value.env,
190190
})
191191
if (called.exitCode !== 0) {
@@ -318,14 +318,14 @@ export const sqlmeshLspExec = async (): Promise<
318318
message: resolvedPath.error,
319319
})
320320
}
321-
const workspacePath = resolvedPath.value
321+
const workspacePath = resolvedPath.value.workspaceFolder
322322

323323
const configuredLSPExec = getSqlmeshLspEntryPoint()
324324
if (configuredLSPExec) {
325325
traceLog(`Using configured SQLMesh LSP entry point: ${configuredLSPExec.entrypoint} ${configuredLSPExec.args.join(' ')}`)
326326
return ok({
327327
bin: configuredLSPExec.entrypoint,
328-
workspacePath,
328+
workspacePath: workspacePath,
329329
env: process.env,
330330
args: configuredLSPExec.args,
331331
})
@@ -381,7 +381,7 @@ export const sqlmeshLspExec = async (): Promise<
381381
if (isSemVerGreaterThanOrEqual(tcloudBinVersion.value, [2, 10, 1])) {
382382
return ok ({
383383
bin: tcloudBin.value.bin,
384-
workspacePath,
384+
workspacePath: workspacePath,
385385
env: tcloudBin.value.env,
386386
args: ['sqlmesh_lsp'],
387387
})
@@ -407,7 +407,7 @@ export const sqlmeshLspExec = async (): Promise<
407407
}
408408
return ok({
409409
bin: binPath,
410-
workspacePath,
410+
workspacePath: workspacePath,
411411
env: env.value,
412412
args: [],
413413
})
@@ -427,7 +427,7 @@ export const sqlmeshLspExec = async (): Promise<
427427
}
428428
return ok({
429429
bin: sqlmeshLSP,
430-
workspacePath,
430+
workspacePath: workspacePath,
431431
env: env.value,
432432
args: [],
433433
})

vscode/extension/tests/lineage.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ test('Lineage panel renders correctly - relative project path', async ({
4040
await fs.copy(SUSHI_SOURCE_PATH, projectDir)
4141

4242
const settings = {
43-
'sqlmesh.projectPath': './projects/sushi',
43+
'sqlmesh.projectPaths': ['./projects/sushi'],
4444
'python.defaultInterpreterPath': sharedCodeServer.defaultPythonInterpreter,
4545
}
4646
await fs.ensureDir(path.join(tempDir, '.vscode'))
@@ -67,7 +67,7 @@ test('Lineage panel renders correctly - absolute project path', async ({
6767
await fs.copy(SUSHI_SOURCE_PATH, projectDir)
6868

6969
const settings = {
70-
'sqlmesh.projectPath': projectDir,
70+
'sqlmesh.projectPaths': [projectDir],
7171
'python.defaultInterpreterPath': sharedCodeServer.defaultPythonInterpreter,
7272
}
7373
await fs.writeJson(path.join(tempDir, '.vscode', 'settings.json'), settings, {
@@ -90,7 +90,7 @@ test('Lineage panel renders correctly - relative project outside of workspace',
9090
await fs.ensureDir(workspaceDir)
9191

9292
const settings = {
93-
'sqlmesh.projectPath': './../projects/sushi',
93+
'sqlmesh.projectPaths': ['./../projects/sushi'],
9494
'python.defaultInterpreterPath': sharedCodeServer.defaultPythonInterpreter,
9595
}
9696
await fs.ensureDir(path.join(workspaceDir, '.vscode'))
@@ -115,7 +115,7 @@ test('Lineage panel renders correctly - absolute path project outside of workspa
115115
await fs.ensureDir(workspaceDir)
116116

117117
const settings = {
118-
'sqlmesh.projectPath': projectDir,
118+
'sqlmesh.projectPaths': [projectDir],
119119
'python.defaultInterpreterPath': sharedCodeServer.defaultPythonInterpreter,
120120
}
121121
await fs.ensureDir(path.join(workspaceDir, '.vscode'))
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { test } from './fixtures'
2+
import {
3+
MULTI_SOURCE_PATH,
4+
openServerPage,
5+
waitForLoadedSQLMesh,
6+
} from './utils'
7+
import fs from 'fs-extra'
8+
9+
test('should work with multi-project setups', async ({
10+
page,
11+
sharedCodeServer,
12+
tempDir,
13+
}) => {
14+
await fs.copy(MULTI_SOURCE_PATH, tempDir)
15+
16+
// Open the server
17+
await openServerPage(page, tempDir, sharedCodeServer)
18+
19+
// Open a model
20+
await page
21+
.getByRole('treeitem', { name: 'repo_1', exact: true })
22+
.locator('a')
23+
.click()
24+
await page
25+
.getByRole('treeitem', { name: 'models', exact: true })
26+
.locator('a')
27+
.click()
28+
await page
29+
.getByRole('treeitem', { name: 'a.sql', exact: true })
30+
.locator('a')
31+
.click()
32+
33+
// Wait for for the project to be loaded
34+
await waitForLoadedSQLMesh(page)
35+
})

vscode/extension/tests/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ export const SUSHI_SOURCE_PATH = path.join(
1414
'examples',
1515
'sushi',
1616
)
17+
export const MULTI_SOURCE_PATH = path.join(
18+
__dirname,
19+
'..',
20+
'..',
21+
'..',
22+
'examples',
23+
'multi',
24+
)
1725
export const REPO_ROOT = path.join(__dirname, '..', '..', '..')
1826

1927
/**

0 commit comments

Comments
 (0)