Skip to content

Commit 02613a6

Browse files
authored
Add likely project root as fallback cwd
Previously, `process.cwd()` was used as a fallback. Unfortunately Electron sets that to `/`. So that didn’t work for plugins that expect a useful cwd. Now, we instead pick the folder of the closest `package.json` or `.git` folder, and use that as the `cwd` of files. Reviewed-by: Remco Haszing <[email protected]> Reviewed-by: Titus Wormer <[email protected]>
1 parent dba7db6 commit 02613a6

File tree

6 files changed

+144
-35
lines changed

6 files changed

+144
-35
lines changed

lib/index.js

Lines changed: 56 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,10 @@
3232
*/
3333

3434
import path from 'node:path'
35-
import process from 'node:process'
3635
import {PassThrough} from 'node:stream'
3736
import {URL, pathToFileURL, fileURLToPath} from 'node:url'
3837

38+
import {findUp, pathExists} from 'find-up'
3939
import {loadPlugin} from 'load-plugin'
4040
import {engine} from 'unified-engine'
4141
import {VFile} from 'vfile'
@@ -139,10 +139,15 @@ function vfileMessageToDiagnostic(message) {
139139
* Convert language server protocol text document to a vfile.
140140
*
141141
* @param {TextDocument} document
142+
* @param {string} cwd
142143
* @returns {VFile}
143144
*/
144-
function lspDocumentToVfile(document) {
145-
return new VFile({path: new URL(document.uri), value: document.getText()})
145+
function lspDocumentToVfile(document, cwd) {
146+
return new VFile({
147+
cwd,
148+
path: new URL(document.uri),
149+
value: document.getText()
150+
})
146151
}
147152

148153
/**
@@ -179,32 +184,57 @@ export function configureUnifiedLanguageServer(
179184
async function processDocuments(textDocuments, alwaysStringify = false) {
180185
// LSP uses `file:` URLs (hrefs), `unified-engine` expects a paths.
181186
// `process.cwd()` does not add a final slash, but `file:` URLs often do.
182-
const workspacesAsPaths = [...workspaces].map((d) =>
183-
fileURLToPath(d.replace(/\/$/, ''))
184-
)
187+
const workspacesAsPaths = [...workspaces]
188+
.map((d) => d.replace(/[/\\]?$/, ''))
189+
// Sort the longest (closest to the file) first.
190+
.sort((a, b) => b.length - a.length)
185191
/** @type {Map<string, Array<VFile>>} */
186192
const workspacePathToFiles = new Map()
187193

188-
if (workspacesAsPaths.length === 0) {
189-
workspacesAsPaths.push(process.cwd())
190-
}
194+
await Promise.all(
195+
textDocuments.map(async (textDocument) => {
196+
/** @type {string | undefined} */
197+
let cwd
198+
if (workspaces.size === 0) {
199+
cwd = await findUp(
200+
async (dir) => {
201+
const pkgExists = await pathExists(path.join(dir, 'package.json'))
202+
if (pkgExists) {
203+
return dir
204+
}
191205

192-
for (const textDocument of textDocuments) {
193-
const file = lspDocumentToVfile(textDocument)
194-
const [cwd] = workspacesAsPaths
195-
// Every workspace that includes the document.
196-
.filter((d) => file.path.slice(0, d.length + 1) === d + path.sep)
197-
// Sort the longest (closest to the file) first.
198-
.sort((a, b) => b.length - a.length)
199-
200-
// This presumably should not occur: a file outside a workspace.
201-
// So ignore the file.
202-
/* c8 ignore next */
203-
if (!cwd) continue
204-
205-
const files = workspacePathToFiles.get(cwd) || []
206-
workspacePathToFiles.set(cwd, [...files, file])
207-
}
206+
const gitExists = await pathExists(path.join(dir, '.git'))
207+
if (gitExists) {
208+
return dir
209+
}
210+
},
211+
{
212+
cwd: path.dirname(fileURLToPath(textDocument.uri)),
213+
type: 'directory'
214+
}
215+
)
216+
} else {
217+
// Because the workspaces are sorted longest to shortest, the first
218+
// match is closest to the file.
219+
const ancestor = workspacesAsPaths.find((d) =>
220+
textDocument.uri.startsWith(d + '/')
221+
)
222+
if (ancestor) {
223+
cwd = fileURLToPath(ancestor)
224+
}
225+
}
226+
227+
// This presumably should not occur: a file outside a workspace.
228+
// So ignore the file.
229+
/* c8 ignore next */
230+
if (!cwd) return
231+
232+
const file = lspDocumentToVfile(textDocument, cwd)
233+
234+
const files = workspacePathToFiles.get(cwd) || []
235+
workspacePathToFiles.set(cwd, [...files, file])
236+
})
237+
)
208238

209239
/** @type {Array<Promise<Array<VFile>>>} */
210240
const promises = []
@@ -308,7 +338,7 @@ export function configureUnifiedLanguageServer(
308338

309339
for (const file of files) {
310340
// VFile uses a file path, but LSP expects a file URL as a string.
311-
const uri = String(pathToFileURL(file.path))
341+
const uri = String(pathToFileURL(path.resolve(file.cwd, file.path)))
312342
connection.sendDiagnostics({
313343
uri,
314344
version: documentVersions.get(uri),

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
],
3535
"dependencies": {
3636
"@types/unist": "^2.0.0",
37+
"find-up": "^6.0.0",
3738
"load-plugin": "^4.0.0",
3839
"unified-engine": "^9.0.0",
3940
"vfile": "^5.0.0",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}

test/index.js

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import assert from 'node:assert'
66
import {Buffer} from 'node:buffer'
7+
import {promises as fs} from 'node:fs'
78
import process from 'node:process'
89
import {PassThrough} from 'node:stream'
910
import {URL, fileURLToPath} from 'node:url'
@@ -15,7 +16,7 @@ import * as exports from 'unified-language-server'
1516

1617
const sleep = promisify(setTimeout)
1718

18-
const delay = process.platform === 'win32' ? 800 : 400
19+
const delay = process.platform === 'win32' ? 1000 : 400
1920
const timeout = 10_000
2021

2122
test('exports', (t) => {
@@ -888,10 +889,10 @@ test('`textDocument/codeAction` (and diagnostics)', async (t) => {
888889
t.end()
889890
})
890891

891-
test('`initialize` w/ nothing', async (t) => {
892+
test('`initialize` w/ nothing (finds closest `package.json`)', async (t) => {
892893
const stdin = new PassThrough()
893-
const cwd = new URL('.', import.meta.url)
894-
const promise = execa('node', ['remark-with-cwd.js', '--stdio'], {
894+
const cwd = new URL('..', import.meta.url)
895+
const promise = execa('node', ['./test/remark-with-cwd.js', '--stdio'], {
895896
cwd: fileURLToPath(cwd),
896897
input: stdin,
897898
timeout
@@ -919,7 +920,10 @@ test('`initialize` w/ nothing', async (t) => {
919920
/** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */
920921
params: {
921922
textDocument: {
922-
uri: new URL('lsp.md', import.meta.url).href,
923+
uri: new URL(
924+
'folder-with-package-json/folder/file.md',
925+
import.meta.url
926+
).href,
923927
languageId: 'markdown',
924928
version: 1,
925929
text: '# hi'
@@ -948,8 +952,79 @@ test('`initialize` w/ nothing', async (t) => {
948952
t.ok(info, 'should emit the cwd')
949953
t.deepEqual(
950954
info.message,
951-
fileURLToPath(cwd).slice(0, -1),
952-
'should default to a `cwd` of `process.cwd()`'
955+
fileURLToPath(new URL('folder-with-package-json', import.meta.url).href),
956+
'should default to a `cwd` of the parent folder of the closest `package.json`'
957+
)
958+
}
959+
960+
t.end()
961+
})
962+
963+
test('`initialize` w/ nothing (find closest `.git`)', async (t) => {
964+
const stdin = new PassThrough()
965+
const cwd = new URL('..', import.meta.url)
966+
await fs.mkdir(new URL('folder-with-git/.git', import.meta.url), {
967+
recursive: true
968+
})
969+
const promise = execa('node', ['./test/remark-with-cwd.js', '--stdio'], {
970+
cwd: fileURLToPath(cwd),
971+
input: stdin,
972+
timeout
973+
})
974+
975+
stdin.write(
976+
toMessage({
977+
method: 'initialize',
978+
id: 0,
979+
/** @type {import('vscode-languageserver').InitializeParams} */
980+
params: {
981+
processId: null,
982+
rootUri: null,
983+
capabilities: {},
984+
workspaceFolders: null
985+
}
986+
})
987+
)
988+
989+
await sleep(delay)
990+
991+
stdin.write(
992+
toMessage({
993+
method: 'textDocument/didOpen',
994+
/** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */
995+
params: {
996+
textDocument: {
997+
uri: new URL('folder-with-git/folder/file.md', import.meta.url).href,
998+
languageId: 'markdown',
999+
version: 1,
1000+
text: '# hi'
1001+
}
1002+
}
1003+
})
1004+
)
1005+
1006+
await sleep(delay)
1007+
1008+
assert(promise.stdout)
1009+
promise.stdout.on('data', () => setImmediate(() => stdin.end()))
1010+
1011+
try {
1012+
await promise
1013+
t.fail('should reject')
1014+
} catch (error) {
1015+
const exception = /** @type {ExecError} */ (error)
1016+
const messages = fromMessages(exception.stdout)
1017+
t.equal(messages.length, 2, 'should emit messages')
1018+
const parameters =
1019+
/** @type {import('vscode-languageserver').PublishDiagnosticsParams} */ (
1020+
messages[1].params
1021+
)
1022+
const info = parameters.diagnostics[0]
1023+
t.ok(info, 'should emit the cwd')
1024+
t.deepEqual(
1025+
info.message,
1026+
fileURLToPath(new URL('folder-with-git', import.meta.url).href),
1027+
'should default to a `cwd` of the parent folder of the closest `.git`'
9531028
)
9541029
}
9551030

test/remark-with-error.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ import {createUnifiedLanguageServer} from '../index.js'
33
createUnifiedLanguageServer({
44
processorName: 'remark',
55
processorSpecifier: 'remark',
6-
plugins: ['./one-error.js']
6+
// This is resolved from the directory containing package.json
7+
plugins: ['./test/one-error.js']
78
})

test/remark-with-warnings.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ import {createUnifiedLanguageServer} from '../index.js'
33
createUnifiedLanguageServer({
44
processorName: 'remark',
55
processorSpecifier: 'remark',
6-
plugins: ['./lots-of-warnings.js']
6+
// This is resolved from the directory containing package.json
7+
plugins: ['./test/lots-of-warnings.js']
78
})

0 commit comments

Comments
 (0)