All files / languages/sparql/providers sparql-completion-item-provider.ts

97.7% Statements 85/87
92.15% Branches 47/51
100% Functions 9/9
97.67% Lines 84/86

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268                                          38x         38x 38x 38x   38x         12x   12x   12x 1x             11x   11x   11x 1x     10x       11x 1x   10x                     35x 22x             13x   13x 30x   30x 2x   28x 9x       11x       18x   18x 11x     7x   7x       5x   2x                       17x 17x 17x       17x 17x   17x   17x 9x     17x   17x       17x                       17x   17x 17x 17x   17x 17x   17x 27x 26x 26x       17x                                             26x 26x   26x   26x           26x 5x           21x       19x   19x 19x     2x 2x     21x 21x                 21x 10x     21x     2x 2x     21x       17x 17x 17x   17x 26x   26x 21x       17x    
import * as vscode from "vscode";
import { IToken, RdfToken } from "@faubulous/mentor-rdf-parsers";
import { getTokenIndexAtPosition } from '@src/utilities';
import { VocabularyRepository } from '@faubulous/mentor-rdf';
import { IDocumentContextService } from '@src/services/document';
import { ISparqlConnectionRegistry, IGraphManagementService, IDocumentConnectionService } from '@src/languages/sparql/services';
import { TurtleCompletionItemProvider } from "@src/languages/turtle/providers";
import { TurtleDocument } from "@src/languages/turtle";
 
export class SparqlCompletionItemProvider extends TurtleCompletionItemProvider {
	/**
	 * The characters that trigger completion when typed.
	 * 
	 * @remarks The provider will also trigger on any position where the current 
	 * token starts with a trigger character, even if the character itself is 
	 * not yet typed (e.g. when completing an IRI that starts with '<', the 
	 * completion will trigger as soon as the user types '<' or when they start 
	 * typing an IRI that was auto-closed with '>' (e.g. <htt>), without 
	 * requiring them to type another trigger character. This is to provide a 
	 * smoother completion experience for IRIs.
	 */
	public readonly triggerCharacters = new Set([':', '<', '/']);
 
	constructor(
		contextService: IDocumentContextService,
		vocabulary: VocabularyRepository,
		private readonly _connectionRegistry: ISparqlConnectionRegistry,
		private readonly _documentConnectionService: IDocumentConnectionService,
		private readonly _graphService: IGraphManagementService
	) {
		super(contextService, vocabulary);
	}
 
	override async provideCompletionItems(document: vscode.TextDocument, position: vscode.Position, t: vscode.CancellationToken, completion: vscode.CompletionContext): Promise<vscode.CompletionItem[] | vscode.CompletionList | null> {
		// The suggestions are read from the triple store; see the Turtle provider.
		await this._contextService.waitUntilLoaded(document.uri.toString());
 
		const context = this._contextService.getDocumentContext(document, TurtleDocument);
 
		if (!context) {
			return null;
		}
 
		// Tokenize the current document text so that completions are always based on
		// the up-to-date buffer content, also for documents that are not indexed (e.g.
		// untitled documents). The tokens are cached per document version and shared
		// with the other providers that run on the same keystroke.
		const tokens = context.tokenizeDocument(document);
 
		const n = getTokenIndexAtPosition(tokens, position);
 
		if (n < 1) {
			return null;
		}
 
		return this.getCompletionItems(document, context, tokens, n) as Promise<vscode.CompletionItem[] | vscode.CompletionList | null>;
	}
 
	override getCompletionItems(document: vscode.TextDocument, context: TurtleDocument, tokens: IToken[], tokenIndex: number): vscode.ProviderResult<vscode.CompletionItem[] | vscode.CompletionList> {
		if (this.isGraphDefinitionContext(tokens, tokenIndex)) {
			return this.getGraphIriCompletionItems(document, tokens, tokenIndex);
		} else {
			return super.getCompletionItems(document, context, tokens, tokenIndex);
		}
	}
 
	/**
	 * Scans backwards from `tokenIndex` to find the index of the opening `<`
	 * that started the incomplete IRI being typed. Returns `tokenIndex` itself
	 * when the current token already starts with `<` (single-token IRI or
	 * auto-closed IRI such as `<htt>`). Returns -1 when no opening `<` is found.
	 */
	private findIriOpenIndex(tokens: IToken[], tokenIndex: number): number {
		if (tokens[tokenIndex].image.startsWith('<')) {
			return tokenIndex;
		}
 
		// When a URL is partially typed (e.g. `<http://example.org/`), the lexer
		// cannot produce a single IRIREF token (which requires a closing `>`).
		// It emits `<` as an LT token, then the URL fragments as separate tokens.
		// Scan backwards to find that opening `<`.
		const stopImages = new Set([';', '{', '}', '(', ')', '>']);
 
		for (let i = tokenIndex - 1; i >= 0; i--) {
			const image = tokens[i].image;
 
			if (image === '<') {
				return i;
			}
			if (image.startsWith('<') || stopImages.has(image)) {
				break;
			}
		}
 
		return -1;
	}
 
	isGraphDefinitionContext(tokens: IToken[], tokenIndex: number) {
		const n = this.findIriOpenIndex(tokens, tokenIndex);
 
		if (n < 0) {
			return false;
		}
 
		const previousToken = tokens[n - 1];
 
		switch (previousToken?.tokenType.name) {
			case RdfToken.GRAPH.name:
			case RdfToken.FROM.name:
			case RdfToken.NAMED.name:
				return true;
			default:
				return false;
		}
	}
 
	/**
	 * Reads the IRI text the user is currently typing from the raw document content.
	 * Handles multi-token IRIs (incomplete, no closing `>`) by reading from the `<`
	 * opening token through the current token.
	 * @returns The typed IRI value (without `<`/`>`), the closing state, the opening
	 *   token, and the end position — all needed to build replacement ranges.
	 */
	private _readTypedIri(document: vscode.TextDocument, tokens: IToken[], tokenIndex: number) {
		const iriOpenIndex = this.findIriOpenIndex(tokens, tokenIndex);
		const iriOpenToken = tokens[iriOpenIndex];
		const currentToken = tokens[tokenIndex];
 
		// Chevrotain positions are 1-based; VS Code positions are 0-based.
		// endColumn is the last char (1-based), so endColumn (without -1) is the exclusive end in 0-based terms.
		const iriStart = new vscode.Position(iriOpenToken.startLine! - 1, iriOpenToken.startColumn! - 1);
		const iriEnd = new vscode.Position(currentToken.endLine! - 1, currentToken.endColumn!);
 
		let value = document.getText(new vscode.Range(iriStart, iriEnd));
 
		if (value.startsWith('<')) {
			value = value.slice(1);
		}
 
		const alreadyClosed = value.endsWith('>');
 
		Iif (alreadyClosed) {
			value = value.slice(0, -1);
		}
 
		return { value, alreadyClosed, iriOpenToken, iriEnd };
	}
 
	/**
	 * Merges remote endpoint graph IRIs and workspace document/cell IRIs into a
	 * single deduplicated list. Endpoint graphs come first.
	 *
	 * When the document's connection has `autoLoadGraphs` enabled and the graph
	 * service has a cached result, the cache is used (no network round-trip).
	 * Otherwise the existing on-demand query path is used.
	 */
	private async _mergeGraphUris(documentUri: vscode.Uri): Promise<string[]> {
		const connection = this._documentConnectionService.getConnectionForDocument(documentUri);
 
		if (this._graphService.hasGraphsForConnection(connection.id)) {
			const inferenceEnabled = this._connectionRegistry.getInferenceEnabled(connection.id);
			const graphs = this._graphService.getGraphsForConnection(connection.id, inferenceEnabled);
			
			const seen = new Set<string>();
			const result: string[] = [];
 
			for (const iri of graphs) {
				if (!seen.has(iri)) {
					seen.add(iri);
					result.push(iri);
				}
			}
 
			return result;
		} else E{
			return [];
		}
	}
 
	/**
	 * Builds a single completion item for a graph IRI candidate, or returns `null`
	 * when the candidate does not match the typed value.
	 *
	 * Matching strategy:
	 * - **Prefix match**: IRI starts with the typed value → label is the remaining suffix.
	 * - **Substring match**: typed value is `workspace:///` + a search term that appears
	 *   anywhere in the IRI path/fragment → label is the full IRI and the item carries a
	 *   replacement range so the entire typed text is overwritten.
	 */
	private _buildGraphCompletionItem(
		iri: string,
		value: string,
		alreadyClosed: boolean,
		iriOpenToken: any,
		iriEnd: vscode.Position,
	): vscode.CompletionItem | null {
		const workspaceScheme = 'workspace:///';
		const substringTerm = value.startsWith(workspaceScheme) ? value.slice(workspaceScheme.length) : null;
 
		const isPrefixMatch = iri.startsWith(value);
		const isSubstringMatch =
			!isPrefixMatch &&
			substringTerm !== null &&
			substringTerm.length > 0 &&
			iri.startsWith(workspaceScheme) &&
			iri.slice(workspaceScheme.length).includes(substringTerm);
 
		if (!isPrefixMatch && !isSubstringMatch) {
			return null;
		}
 
		let label: string;
		let insertText: string;
 
		if (isPrefixMatch) {
			// Show the full IRI as the label — the suffix alone (e.g. 'orkspace:///…' when 'w'
			// was typed) is confusing — but only insert the part the user has not typed yet so
			// the typed prefix is not duplicated.
			const suffix = iri.substring(value.length);
 
			label = iri;
			insertText = alreadyClosed ? suffix : suffix + '>';
		} else {
			// Substring match: replace everything the user typed with the full IRI.
			label = iri;
			insertText = alreadyClosed ? iri : iri + '>';
		}
 
		const item = new vscode.CompletionItem(label, vscode.CompletionItemKind.Reference);
		item.insertText = new vscode.SnippetString(insertText);
 
		// For workspace IRIs, set filterText to the full IRI with '#' replaced by a space.
		// Keeping the 'workspace:///' scheme means typing it (e.g. 'w', 'wo', 'workspace')
		// matches these graphs at position 0, so VS Code's scorer — which rewards matches
		// nearer the start — ranks them above graphs where the typed text only appears later
		// (e.g. the 'w' in 'www'). Replacing '#' with a space makes VS Code treat slug names
		// after the fragment (e.g. 'cell-1' in 'notebook.mnb#cell-1') as word starts, so the
		// user can still type 'cell' to filter to that cell.
		if (iri.startsWith(workspaceScheme)) {
			item.filterText = iri.replace('#', ' ');
		}
 
		if (isSubstringMatch) {
			// Replace the entire typed text (from after '<' to the current position).
			// iriOpenToken.startColumn is 1-based; +0 gives us column of the char after '<'.
			const contentStart = new vscode.Position(iriOpenToken.startLine! - 1, iriOpenToken.startColumn!);
			item.range = new vscode.Range(contentStart, iriEnd);
		}
 
		return item;
	}
 
	async getGraphIriCompletionItems(document: vscode.TextDocument, tokens: IToken[], tokenIndex: number): Promise<vscode.CompletionItem[]> {
		const { value, alreadyClosed, iriOpenToken, iriEnd } = this._readTypedIri(document, tokens, tokenIndex);
		const graphs = await this._mergeGraphUris(document.uri);
		const result: vscode.CompletionItem[] = [];
 
		for (const iri of graphs) {
			const item = this._buildGraphCompletionItem(iri, value, alreadyClosed, iriOpenToken, iriEnd);
 
			if (item) {
				result.push(item);
			}
		}
 
		return result;
	}
}