fish-lsp
Version:
LSP implementation for fish/fish-shell
318 lines (278 loc) • 9.18 kB
text/typescript
import { promises as fs } from 'fs';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { Position, Range, TextDocumentItem, TextDocumentContentChangeEvent } from 'vscode-languageserver';
import { URI } from 'vscode-uri';
import { homedir } from 'os';
import { AutoloadType, uriToPath } from './utils/translation';
export class LspDocument implements TextDocument {
protected document: TextDocument;
constructor(doc: TextDocumentItem) {
const { uri, languageId, version, text } = doc;
this.document = TextDocument.create(uri, languageId, version, text);
}
get uri(): string {
return this.document.uri;
}
get languageId(): string {
return this.document.languageId;
}
get version(): number {
return this.document.version;
}
getText(range?: Range): string {
return this.document.getText(range);
}
positionAt(offset: number): Position {
return this.document.positionAt(offset);
}
offsetAt(position: Position): number {
return this.document.offsetAt(position);
}
get lineCount(): number {
return this.document.lineCount;
}
/**
* @see getLineBeforeCursor()
*/
getLine(line: number): string {
const lineRange = this.getLineRange(line);
return this.getText(lineRange);
}
getLineBeforeCursor(position: Position): string {
const lineStart = Position.create(position.line, 0);
const lineEnd = Position.create(position.line, position.character);
const lineRange = Range.create(lineStart, lineEnd);
return this.getText(lineRange);
}
getLineRange(line: number): Range {
const lineStart = this.getLineStart(line);
const lineEnd = this.getLineEnd(line);
return Range.create(lineStart, lineEnd);
}
getLineEnd(line: number): Position {
const nextLineOffset = this.getLineOffset(line + 1);
return this.positionAt(nextLineOffset - 1);
}
getLineOffset(line: number): number {
const lineStart = this.getLineStart(line);
return this.offsetAt(lineStart);
}
getLineStart(line: number): Position {
return Position.create(line, 0);
}
getIndentAtLine(line: number): string {
const lineText = this.getLine(line);
const indent = lineText.match(/^\s+/);
return indent ? indent[0] : '';
}
applyEdits(version: number, ...changes: TextDocumentContentChangeEvent[]): void {
for (const change of changes) {
const content = this.getText();
let newContent = change.text;
if (TextDocumentContentChangeEvent.isIncremental(change)) {
const start = this.offsetAt(change.range.start);
const end = this.offsetAt(change.range.end);
newContent = content.substring(0, start) + change.text + content.substring(end);
}
this.document = TextDocument.create(this.uri, this.languageId, version, newContent);
}
}
rename(newUri: string): void {
this.document = TextDocument.create(newUri, this.languageId, this.version, this.getText());
}
getFilePath(): string | undefined {
return uriToPath(this.uri);
}
getFilename(): string {
return this.uri.split('/').pop() as string;
}
getRelativeFilenameToWorkspace(): string {
const home = homedir();
const path = this.uri.replace(home, '~');
const dirs = path.split('/');
const workspaceRootIndex = dirs.find(dir => dir === 'fish')
? dirs.indexOf('fish')
: dirs.find(dir => ['conf.d', 'functions', 'completions', 'config.fish'].includes(dir))
? dirs.findLastIndex(dir => ['conf.d', 'functions', 'completions', 'config.fish'].includes(dir))
: dirs.length - 1;
return dirs.slice(workspaceRootIndex).join('/');
}
/**
* checks if the functions are defined in a functions directory
*/
isFunction(): boolean {
const pathArray = this.uri.split('/');
const fileName = pathArray.pop();
const parentDir = pathArray.pop();
/** paths that autoload all top level functions to the shell env */
if (parentDir === 'conf.d' || fileName === 'config.fish') {
return true;
}
/** path that autoload matching filename functions to the shell env */
return parentDir === 'functions';
}
shouldAnalyzeInBackground(): boolean {
const pathArray = this.uri.split('/');
const fileName = pathArray.pop();
const parentDir = pathArray.pop();
return parentDir && ['functions', 'conf.d'].includes(parentDir?.toString()) || fileName === 'config.fish';
}
/**
* checks if the document is in a location where the functions
* that it defines are autoloaded by fish.
*
* Use isAutoloadedUri() if you want to check for completions
* files as well. This function does not check for completion
* files.
*/
isAutoloaded(): boolean {
const path = uriToPath(this.uri);
if (path?.includes('fish/functions')) {
return true;
} else if (path?.includes('fish/conf.d')) {
return true;
} else if (path?.includes('fish/config.fish')) {
return true;
}
return false;
}
/**
* checks if the document is in a location:
* - `fish/{conf.d,functions,completions}/file.fish`
* - `fish/config.fish`
*
* Key difference from isAutoLoaded is that this function checks for
* completions files as well. isAutoloaded() does not check for
* completion files.
*/
isAutoloadedUri(): boolean {
const path = uriToPath(this.uri);
if (path?.includes('fish/functions')) {
return true;
} else if (path?.includes('fish/conf.d')) {
return true;
} else if (path?.includes('fish/config.fish')) {
return true;
} else if (path?.includes('fish/completions')) {
return true;
}
return false;
}
/**
* helper that gets the document URI if it is fish/functions directory
*/
getAutoloadType(): AutoloadType {
const path = uriToPath(this.uri);
if (path?.includes('fish/functions')) {
return 'functions';
} else if (path?.includes('fish/conf.d')) {
return 'conf.d';
} else if (path?.includes('fish/config.fish')) {
return 'config';
} else if (path?.includes('fish/completions')) {
return 'completions';
}
return '';
}
/**
* helper that gets the document URI if it is fish/functions directory
* @returns {string} - what the function name should be, or '' if it is not autoloaded
*/
getAutoLoadName(): string {
if (!this.isAutoloaded()) {
return '';
}
const parts = uriToPath(this.uri)?.split('/') || [];
const name = parts[parts.length - 1];
return name!.replace('.fish', '');
}
getLines(): number {
const lines = this.getText().split('\n');
return lines.length;
}
}
export class LspDocuments {
private readonly _files: string[] = [];
private readonly documents = new Map<string, LspDocument>();
private loadingQueue: Set<string> = new Set();
private loadedFiles: Map<string, number> = new Map(); // uri -> timestamp
/**
* Sorted by last access.
*/
get files(): string[] {
return this._files;
}
get(file?: string): LspDocument | undefined {
if (!file) {
return undefined;
}
const document = this.documents.get(file);
if (!document) {
return undefined;
}
if (this.files[0] !== file) {
this._files.splice(this._files.indexOf(file), 1);
this._files.unshift(file);
}
return document;
}
// Enhanced get method that supports async loading
async getAsync(uri?: string): Promise<LspDocument | undefined> {
if (!uri) return undefined;
return this.getDocument(uri);
}
async getDocument(uri: string): Promise<LspDocument | undefined> {
if (!this.loadingQueue.has(uri) && !this.loadedFiles.has(uri)) {
this.loadingQueue.add(uri);
try {
const content = await fs.readFile(uriToPath(uri), 'utf8');
const doc = new LspDocument({
uri,
languageId: 'fish',
version: 1,
text: content,
});
this.documents.set(uri, doc);
this.loadedFiles.set(uri, Date.now());
} finally {
this.loadingQueue.delete(uri);
}
}
return this.documents.get(uri);
}
open(file: string, doc: TextDocumentItem): boolean {
if (this.documents.has(file)) {
return false;
}
this.documents.set(file, new LspDocument(doc));
this._files.unshift(file);
return true;
}
close(file: string): LspDocument | undefined {
const document = this.documents.get(file);
if (!document) {
return undefined;
}
this.documents.delete(file);
this._files.splice(this._files.indexOf(file), 1);
return document;
}
rename(oldFile: string, newFile: string): boolean {
const document = this.documents.get(oldFile);
if (!document) {
return false;
}
document.rename(newFile);
this.documents.delete(oldFile);
this.documents.set(newFile, document);
this._files[this._files.indexOf(oldFile)] = newFile;
return true;
}
public toResource(filepath: string): URI {
const document = this.documents.get(filepath);
if (document) {
return URI.parse(document.uri);
}
return URI.file(filepath);
}
}