Skip to content

Commit

Permalink
Add likely project root as fallback cwd
Browse files Browse the repository at this point in the history
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 <remcohaszing@gmail.com>
Reviewed-by: Titus Wormer <tituswormer@gmail.com>
  • Loading branch information
remcohaszing authored Mar 4, 2022
1 parent dba7db6 commit 02613a6
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 35 deletions.
82 changes: 56 additions & 26 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@
*/

import path from 'node:path'
import process from 'node:process'
import {PassThrough} from 'node:stream'
import {URL, pathToFileURL, fileURLToPath} from 'node:url'

import {findUp, pathExists} from 'find-up'
import {loadPlugin} from 'load-plugin'
import {engine} from 'unified-engine'
import {VFile} from 'vfile'
Expand Down Expand Up @@ -139,10 +139,15 @@ function vfileMessageToDiagnostic(message) {
* Convert language server protocol text document to a vfile.
*
* @param {TextDocument} document
* @param {string} cwd
* @returns {VFile}
*/
function lspDocumentToVfile(document) {
return new VFile({path: new URL(document.uri), value: document.getText()})
function lspDocumentToVfile(document, cwd) {
return new VFile({
cwd,
path: new URL(document.uri),
value: document.getText()
})
}

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

if (workspacesAsPaths.length === 0) {
workspacesAsPaths.push(process.cwd())
}
await Promise.all(
textDocuments.map(async (textDocument) => {
/** @type {string | undefined} */
let cwd
if (workspaces.size === 0) {
cwd = await findUp(
async (dir) => {
const pkgExists = await pathExists(path.join(dir, 'package.json'))
if (pkgExists) {
return dir
}

for (const textDocument of textDocuments) {
const file = lspDocumentToVfile(textDocument)
const [cwd] = workspacesAsPaths
// Every workspace that includes the document.
.filter((d) => file.path.slice(0, d.length + 1) === d + path.sep)
// Sort the longest (closest to the file) first.
.sort((a, b) => b.length - a.length)

// This presumably should not occur: a file outside a workspace.
// So ignore the file.
/* c8 ignore next */
if (!cwd) continue

const files = workspacePathToFiles.get(cwd) || []
workspacePathToFiles.set(cwd, [...files, file])
}
const gitExists = await pathExists(path.join(dir, '.git'))
if (gitExists) {
return dir
}
},
{
cwd: path.dirname(fileURLToPath(textDocument.uri)),
type: 'directory'
}
)
} else {
// Because the workspaces are sorted longest to shortest, the first
// match is closest to the file.
const ancestor = workspacesAsPaths.find((d) =>
textDocument.uri.startsWith(d + '/')
)
if (ancestor) {
cwd = fileURLToPath(ancestor)
}
}

// This presumably should not occur: a file outside a workspace.
// So ignore the file.
/* c8 ignore next */
if (!cwd) return

const file = lspDocumentToVfile(textDocument, cwd)

const files = workspacePathToFiles.get(cwd) || []
workspacePathToFiles.set(cwd, [...files, file])
})
)

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

for (const file of files) {
// VFile uses a file path, but LSP expects a file URL as a string.
const uri = String(pathToFileURL(file.path))
const uri = String(pathToFileURL(path.resolve(file.cwd, file.path)))
connection.sendDiagnostics({
uri,
version: documentVersions.get(uri),
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
],
"dependencies": {
"@types/unist": "^2.0.0",
"find-up": "^6.0.0",
"load-plugin": "^4.0.0",
"unified-engine": "^9.0.0",
"vfile": "^5.0.0",
Expand Down
1 change: 1 addition & 0 deletions test/folder-with-package-json/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
89 changes: 82 additions & 7 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import assert from 'node:assert'
import {Buffer} from 'node:buffer'
import {promises as fs} from 'node:fs'
import process from 'node:process'
import {PassThrough} from 'node:stream'
import {URL, fileURLToPath} from 'node:url'
Expand All @@ -15,7 +16,7 @@ import * as exports from 'unified-language-server'

const sleep = promisify(setTimeout)

const delay = process.platform === 'win32' ? 800 : 400
const delay = process.platform === 'win32' ? 1000 : 400
const timeout = 10_000

test('exports', (t) => {
Expand Down Expand Up @@ -888,10 +889,10 @@ test('`textDocument/codeAction` (and diagnostics)', async (t) => {
t.end()
})

test('`initialize` w/ nothing', async (t) => {
test('`initialize` w/ nothing (finds closest `package.json`)', async (t) => {
const stdin = new PassThrough()
const cwd = new URL('.', import.meta.url)
const promise = execa('node', ['remark-with-cwd.js', '--stdio'], {
const cwd = new URL('..', import.meta.url)
const promise = execa('node', ['./test/remark-with-cwd.js', '--stdio'], {
cwd: fileURLToPath(cwd),
input: stdin,
timeout
Expand Down Expand Up @@ -919,7 +920,10 @@ test('`initialize` w/ nothing', async (t) => {
/** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */
params: {
textDocument: {
uri: new URL('lsp.md', import.meta.url).href,
uri: new URL(
'folder-with-package-json/folder/file.md',
import.meta.url
).href,
languageId: 'markdown',
version: 1,
text: '# hi'
Expand Down Expand Up @@ -948,8 +952,79 @@ test('`initialize` w/ nothing', async (t) => {
t.ok(info, 'should emit the cwd')
t.deepEqual(
info.message,
fileURLToPath(cwd).slice(0, -1),
'should default to a `cwd` of `process.cwd()`'
fileURLToPath(new URL('folder-with-package-json', import.meta.url).href),
'should default to a `cwd` of the parent folder of the closest `package.json`'
)
}

t.end()
})

test('`initialize` w/ nothing (find closest `.git`)', async (t) => {
const stdin = new PassThrough()
const cwd = new URL('..', import.meta.url)
await fs.mkdir(new URL('folder-with-git/.git', import.meta.url), {
recursive: true
})
const promise = execa('node', ['./test/remark-with-cwd.js', '--stdio'], {
cwd: fileURLToPath(cwd),
input: stdin,
timeout
})

stdin.write(
toMessage({
method: 'initialize',
id: 0,
/** @type {import('vscode-languageserver').InitializeParams} */
params: {
processId: null,
rootUri: null,
capabilities: {},
workspaceFolders: null
}
})
)

await sleep(delay)

stdin.write(
toMessage({
method: 'textDocument/didOpen',
/** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */
params: {
textDocument: {
uri: new URL('folder-with-git/folder/file.md', import.meta.url).href,
languageId: 'markdown',
version: 1,
text: '# hi'
}
}
})
)

await sleep(delay)

assert(promise.stdout)
promise.stdout.on('data', () => setImmediate(() => stdin.end()))

try {
await promise
t.fail('should reject')
} catch (error) {
const exception = /** @type {ExecError} */ (error)
const messages = fromMessages(exception.stdout)
t.equal(messages.length, 2, 'should emit messages')
const parameters =
/** @type {import('vscode-languageserver').PublishDiagnosticsParams} */ (
messages[1].params
)
const info = parameters.diagnostics[0]
t.ok(info, 'should emit the cwd')
t.deepEqual(
info.message,
fileURLToPath(new URL('folder-with-git', import.meta.url).href),
'should default to a `cwd` of the parent folder of the closest `.git`'
)
}

Expand Down
3 changes: 2 additions & 1 deletion test/remark-with-error.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ import {createUnifiedLanguageServer} from '../index.js'
createUnifiedLanguageServer({
processorName: 'remark',
processorSpecifier: 'remark',
plugins: ['./one-error.js']
// This is resolved from the directory containing package.json
plugins: ['./test/one-error.js']
})
3 changes: 2 additions & 1 deletion test/remark-with-warnings.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ import {createUnifiedLanguageServer} from '../index.js'
createUnifiedLanguageServer({
processorName: 'remark',
processorSpecifier: 'remark',
plugins: ['./lots-of-warnings.js']
// This is resolved from the directory containing package.json
plugins: ['./test/lots-of-warnings.js']
})

0 comments on commit 02613a6

Please sign in to comment.