diff --git a/editors/vscode/images/icons/trilogy.png b/editors/vscode/images/icons/trilogy.png new file mode 100644 index 0000000..6079c2b Binary files /dev/null and b/editors/vscode/images/icons/trilogy.png differ diff --git a/editors/vscode/package.json b/editors/vscode/package.json index d6a9de3..98f5a21 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -4,21 +4,15 @@ "author": "N/A", "repository": "https://github.com/trilogydata/trilogy-language-server", "license": "MIT", - "version": "0.1.27", + "version": "0.1.28", "publisher": "trilogydata", "engines": { - "vscode": "^1.73.0" + "vscode": "^1.75.0" }, "categories": [ "Programming Languages" ], - "activationEvents": [ - "onLanguage:trilogy", - "onCommand:trilogy.runQuery", - "onCommand:trilogy.renderQuery", - "onCommand:trilogy.serveFolder", - "onCommand:trilogy.stopServe" - ], + "icon": "images/trilogy.png", "contributes": { "viewsContainers": { "activitybar": [ @@ -168,4 +162,4 @@ "typescript": "^5.5.2", "vscode-tmgrammar-test": "^0.1.3" } -} +} \ No newline at end of file diff --git a/trilogy_language_server/models.py b/trilogy_language_server/models.py index e12a4e8..cdfa0f3 100644 --- a/trilogy_language_server/models.py +++ b/trilogy_language_server/models.py @@ -49,3 +49,28 @@ class ConceptLocation(BaseModel): end_line: int end_column: int is_definition: bool = False + + +class DatasourceInfo(BaseModel): + """Information about a datasource for hover tooltips.""" + + name: str + address: str + columns: List[str] = Field(default_factory=list) + grain: List[str] = Field(default_factory=list) + start_line: int + start_column: int + end_line: int + end_column: int + is_root: bool = False + + +class ImportInfo(BaseModel): + """Information about an import statement for hover tooltips.""" + + path: str + alias: Optional[str] = None + start_line: int + start_column: int + end_line: int + end_column: int diff --git a/trilogy_language_server/parsing.py b/trilogy_language_server/parsing.py index 7057f94..cd9ca7e 100644 --- a/trilogy_language_server/parsing.py +++ b/trilogy_language_server/parsing.py @@ -3,11 +3,20 @@ TokenModifier, ConceptInfo, ConceptLocation, + DatasourceInfo, + ImportInfo, ) from trilogy.parsing.parse_engine import PARSER from lark import ParseTree, Token as LarkToken -from typing import List, Union, Dict, Optional -from lsprotocol.types import CodeLens, Range, Position, Command +from typing import List, Union, Dict, Optional, Any +from lsprotocol.types import ( + CodeLens, + Range, + Position, + Command, + DocumentSymbol, + SymbolKind, +) from trilogy.parsing.parse_engine import ParseToObjects as ParseToObjects from trilogy.core.statements.author import ( SelectStatement, @@ -508,3 +517,548 @@ def format_concept_hover(concept: ConceptInfo, is_definition: bool = False) -> s lines.append(f"*Full address: `{concept.address}`*") return "\n".join(lines) + + +def get_definition_locations( + locations: List[ConceptLocation], concept_address: str +) -> List[ConceptLocation]: + """ + Find all definition locations for a given concept address. + """ + definitions = [] + for loc in locations: + if loc.is_definition and loc.concept_address == concept_address: + definitions.append(loc) + return definitions + + +def extract_datasource_info(tree: ParseTree) -> List[DatasourceInfo]: + """ + Extract datasource information from the parse tree for hover tooltips. + """ + datasources: List[DatasourceInfo] = [] + + def walk_tree(node: Union[ParseTree, LarkToken]): + if isinstance(node, LarkToken): + return + + node_data = getattr(node, "data", None) + + if node_data == "datasource": + # Extract datasource information + name = "" + address = "" + columns = [] + grain = [] + is_root = False + start_line = 1 + start_column = 1 + end_line = 1 + end_column = 1 + + for child in node.children: + if isinstance(child, LarkToken): + if child.type == "IDENTIFIER" and not name: + name = str(child) + start_line = child.line or 1 + start_column = child.column or 1 + elif child.type == "IDENTIFIER": + address = str(child) + end_line = child.end_line or child.line or 1 + end_column = child.end_column or 100 + else: + # It's a tree node + child_data = getattr(child, "data", None) + if child_data == "column_list": + for col_child in child.children: + if ( + isinstance(col_child, LarkToken) + and col_child.type == "IDENTIFIER" + ): + columns.append(str(col_child)) + elif child_data == "grain_clause": + for grain_child in child.children: + if ( + isinstance(grain_child, LarkToken) + and grain_child.type == "IDENTIFIER" + ): + grain.append(str(grain_child)) + + if name: + datasources.append( + DatasourceInfo( + name=name, + address=address or name, + columns=columns, + grain=grain, + start_line=start_line, + start_column=start_column, + end_line=end_line, + end_column=end_column, + is_root=is_root, + ) + ) + elif node_data == "root_datasource": + # Handle root datasource syntax + name = "" + address = "" + columns = [] + grain = [] + start_line = 1 + start_column = 1 + end_line = 1 + end_column = 1 + + for child in node.children: + if isinstance(child, LarkToken): + if child.type == "IDENTIFIER" and not name: + name = str(child) + start_line = child.line or 1 + start_column = child.column or 1 + elif child.type == "IDENTIFIER": + address = str(child) + end_line = child.end_line or child.line or 1 + end_column = child.end_column or 100 + else: + # It's a tree node + child_data = getattr(child, "data", None) + if child_data == "column_list": + for col_child in child.children: + if ( + isinstance(col_child, LarkToken) + and col_child.type == "IDENTIFIER" + ): + columns.append(str(col_child)) + elif child_data == "grain_clause": + for grain_child in child.children: + if ( + isinstance(grain_child, LarkToken) + and grain_child.type == "IDENTIFIER" + ): + grain.append(str(grain_child)) + + if name: + datasources.append( + DatasourceInfo( + name=name, + address=address or name, + columns=columns, + grain=grain, + start_line=start_line, + start_column=start_column, + end_line=end_line, + end_column=end_column, + is_root=True, + ) + ) + else: + for child in node.children: + walk_tree(child) + + walk_tree(tree) + return datasources + + +def extract_import_info(tree: ParseTree) -> List[ImportInfo]: + """ + Extract import information from the parse tree for hover tooltips. + """ + imports: List[ImportInfo] = [] + + def walk_tree(node: Union[ParseTree, LarkToken]): + if isinstance(node, LarkToken): + return + + node_data = getattr(node, "data", None) + + if node_data == "import_statement": + # Extract import information + path = "" + alias = None + start_line = 1 + start_column = 1 + end_line = 1 + end_column = 1 + + for child in node.children: + if isinstance(child, LarkToken): + if child.type in ("IDENTIFIER", "DOTTED_NAME", "FILEPATH"): + if not path: + path = str(child) + start_line = child.line or 1 + start_column = child.column or 1 + end_line = child.end_line or child.line or 1 + end_column = child.end_column or 100 + else: + # This is the alias + alias = str(child) + end_line = child.end_line or child.line or 1 + end_column = child.end_column or 100 + + if path: + imports.append( + ImportInfo( + path=path, + alias=alias, + start_line=start_line, + start_column=start_column, + end_line=end_line, + end_column=end_column, + ) + ) + else: + for child in node.children: + walk_tree(child) + + walk_tree(tree) + return imports + + +def format_datasource_hover(ds: DatasourceInfo) -> str: + """ + Format datasource information as markdown for hover display. + """ + lines = [] + + if ds.is_root: + lines.append(f"**root datasource** `{ds.name}`") + else: + lines.append(f"**datasource** `{ds.name}`") + lines.append("") + + lines.append(f"**Address:** `{ds.address}`") + + if ds.columns: + cols_str = ", ".join(f"`{c}`" for c in ds.columns[:10]) + if len(ds.columns) > 10: + cols_str += f" ... ({len(ds.columns)} total)" + lines.append(f"**Columns:** {cols_str}") + + if ds.grain: + grain_str = ", ".join(f"`{g}`" for g in ds.grain) + lines.append(f"**Grain:** {grain_str}") + + return "\n".join(lines) + + +def format_import_hover(imp: ImportInfo) -> str: + """ + Format import information as markdown for hover display. + """ + lines = [] + + lines.append("**import statement**") + lines.append("") + lines.append(f"**Path:** `{imp.path}`") + + if imp.alias: + lines.append(f"**Alias:** `{imp.alias}`") + lines.append("") + lines.append( + f"*Use `{imp.alias}.concept_name` to reference concepts from this import*" + ) + + return "\n".join(lines) + + +def get_document_symbols( + locations: List[ConceptLocation], + concept_info_map: Dict[str, ConceptInfo], + datasources: List[DatasourceInfo], + imports: List[ImportInfo], +) -> List[DocumentSymbol]: + """ + Generate document symbols for the outline/navigation view. + """ + symbols: List[DocumentSymbol] = [] + + # Add concept definitions + for loc in locations: + if not loc.is_definition: + continue + + concept = resolve_concept_address(loc.concept_address, concept_info_map) + if not concept: + continue + + # Determine symbol kind based on purpose + kind = SymbolKind.Variable + if concept.purpose == "key": + kind = SymbolKind.Key + elif concept.purpose == "property": + kind = SymbolKind.Property + elif concept.purpose == "metric": + kind = SymbolKind.Number + elif concept.purpose == "constant": + kind = SymbolKind.Constant + + symbol_range = Range( + start=Position(line=loc.start_line - 1, character=loc.start_column - 1), + end=Position(line=loc.end_line - 1, character=loc.end_column - 1), + ) + + symbols.append( + DocumentSymbol( + name=concept.name, + kind=kind, + range=symbol_range, + selection_range=symbol_range, + detail=f"{concept.purpose}: {concept.datatype}", + ) + ) + + # Add datasources + for ds in datasources: + symbol_range = Range( + start=Position(line=ds.start_line - 1, character=ds.start_column - 1), + end=Position(line=ds.end_line - 1, character=ds.end_column - 1), + ) + + symbols.append( + DocumentSymbol( + name=ds.name, + kind=SymbolKind.Struct, + range=symbol_range, + selection_range=symbol_range, + detail=f"datasource -> {ds.address}", + ) + ) + + # Add imports + for imp in imports: + symbol_range = Range( + start=Position(line=imp.start_line - 1, character=imp.start_column - 1), + end=Position(line=imp.end_line - 1, character=imp.end_column - 1), + ) + + name = imp.alias if imp.alias else imp.path + symbols.append( + DocumentSymbol( + name=name, + kind=SymbolKind.Module, + range=symbol_range, + selection_range=symbol_range, + detail=f"import {imp.path}", + ) + ) + + # Sort by line number + symbols.sort(key=lambda s: s.range.start.line) + + return symbols + + +# Trilogy built-in functions with signature information for signature help +TRILOGY_FUNCTIONS: dict[str, dict[str, Any]] = { + "count": { + "signature": "count(concept) -> int", + "description": "Count the number of distinct values of a concept.", + "parameters": [ + { + "name": "concept", + "description": "The concept to count distinct values of", + } + ], + }, + "sum": { + "signature": "sum(concept) -> numeric", + "description": "Calculate the sum of all values of a concept.", + "parameters": [ + {"name": "concept", "description": "The numeric concept to sum"} + ], + }, + "avg": { + "signature": "avg(concept) -> float", + "description": "Calculate the average of all values of a concept.", + "parameters": [ + {"name": "concept", "description": "The numeric concept to average"} + ], + }, + "min": { + "signature": "min(concept) -> value", + "description": "Find the minimum value of a concept.", + "parameters": [ + {"name": "concept", "description": "The concept to find the minimum of"} + ], + }, + "max": { + "signature": "max(concept) -> value", + "description": "Find the maximum value of a concept.", + "parameters": [ + {"name": "concept", "description": "The concept to find the maximum of"} + ], + }, + "coalesce": { + "signature": "coalesce(value1, value2, ...) -> value", + "description": "Return the first non-null value from the arguments.", + "parameters": [ + {"name": "value1", "description": "First value to check"}, + {"name": "value2", "description": "Second value to check (optional)"}, + ], + }, + "concat": { + "signature": "concat(string1, string2, ...) -> string", + "description": "Concatenate multiple strings together.", + "parameters": [ + {"name": "string1", "description": "First string"}, + {"name": "string2", "description": "Second string"}, + ], + }, + "length": { + "signature": "length(string) -> int", + "description": "Return the length of a string.", + "parameters": [{"name": "string", "description": "The string to measure"}], + }, + "upper": { + "signature": "upper(string) -> string", + "description": "Convert a string to uppercase.", + "parameters": [{"name": "string", "description": "The string to convert"}], + }, + "lower": { + "signature": "lower(string) -> string", + "description": "Convert a string to lowercase.", + "parameters": [{"name": "string", "description": "The string to convert"}], + }, + "trim": { + "signature": "trim(string) -> string", + "description": "Remove leading and trailing whitespace from a string.", + "parameters": [{"name": "string", "description": "The string to trim"}], + }, + "substring": { + "signature": "substring(string, start, length) -> string", + "description": "Extract a substring from a string.", + "parameters": [ + {"name": "string", "description": "The source string"}, + {"name": "start", "description": "Starting position (1-indexed)"}, + {"name": "length", "description": "Number of characters to extract"}, + ], + }, + "abs": { + "signature": "abs(value) -> numeric", + "description": "Return the absolute value of a number.", + "parameters": [{"name": "value", "description": "The numeric value"}], + }, + "round": { + "signature": "round(value, decimals?) -> numeric", + "description": "Round a number to the specified number of decimal places.", + "parameters": [ + {"name": "value", "description": "The numeric value to round"}, + { + "name": "decimals", + "description": "Number of decimal places (default: 0)", + }, + ], + }, + "floor": { + "signature": "floor(value) -> int", + "description": "Round a number down to the nearest integer.", + "parameters": [{"name": "value", "description": "The numeric value"}], + }, + "ceil": { + "signature": "ceil(value) -> int", + "description": "Round a number up to the nearest integer.", + "parameters": [{"name": "value", "description": "The numeric value"}], + }, + "date": { + "signature": "date(year, month, day) -> date", + "description": "Create a date from year, month, and day components.", + "parameters": [ + {"name": "year", "description": "The year"}, + {"name": "month", "description": "The month (1-12)"}, + {"name": "day", "description": "The day of the month"}, + ], + }, + "year": { + "signature": "year(date) -> int", + "description": "Extract the year from a date.", + "parameters": [{"name": "date", "description": "The date to extract from"}], + }, + "month": { + "signature": "month(date) -> int", + "description": "Extract the month from a date.", + "parameters": [{"name": "date", "description": "The date to extract from"}], + }, + "day": { + "signature": "day(date) -> int", + "description": "Extract the day from a date.", + "parameters": [{"name": "date", "description": "The date to extract from"}], + }, + "now": { + "signature": "now() -> timestamp", + "description": "Return the current timestamp.", + "parameters": [], + }, + "today": { + "signature": "today() -> date", + "description": "Return the current date.", + "parameters": [], + }, + "cast": { + "signature": "cast(value, type) -> value", + "description": "Cast a value to a different data type.", + "parameters": [ + {"name": "value", "description": "The value to cast"}, + {"name": "type", "description": "The target data type"}, + ], + }, + "case": { + "signature": "case(when condition then value, ..., else default) -> value", + "description": "Conditional expression that returns different values based on conditions.", + "parameters": [ + {"name": "condition", "description": "Boolean condition to test"}, + {"name": "value", "description": "Value to return if condition is true"}, + ], + }, + "if": { + "signature": "if(condition, then_value, else_value) -> value", + "description": "Return one of two values based on a condition.", + "parameters": [ + {"name": "condition", "description": "Boolean condition to test"}, + { + "name": "then_value", + "description": "Value to return if condition is true", + }, + { + "name": "else_value", + "description": "Value to return if condition is false", + }, + ], + }, + "nullif": { + "signature": "nullif(value1, value2) -> value", + "description": "Return NULL if value1 equals value2, otherwise return value1.", + "parameters": [ + { + "name": "value1", + "description": "The value to compare and potentially return", + }, + {"name": "value2", "description": "The value to compare against"}, + ], + }, + "like": { + "signature": "like(string, pattern) -> bool", + "description": "Check if a string matches a pattern (using % and _ wildcards).", + "parameters": [ + {"name": "string", "description": "The string to match"}, + {"name": "pattern", "description": "The pattern to match against"}, + ], + }, + "unnest": { + "signature": "unnest(array) -> values", + "description": "Expand an array into multiple rows.", + "parameters": [{"name": "array", "description": "The array to expand"}], + }, + "array_agg": { + "signature": "array_agg(value) -> array", + "description": "Aggregate values into an array.", + "parameters": [{"name": "value", "description": "The values to aggregate"}], + }, + "string_agg": { + "signature": "string_agg(value, separator) -> string", + "description": "Concatenate values into a string with a separator.", + "parameters": [ + {"name": "value", "description": "The values to concatenate"}, + {"name": "separator", "description": "The separator between values"}, + ], + }, +} diff --git a/trilogy_language_server/server.py b/trilogy_language_server/server.py index 9d8d5cf..cd29e87 100644 --- a/trilogy_language_server/server.py +++ b/trilogy_language_server/server.py @@ -6,6 +6,8 @@ CompletionItem, CompletionList, CompletionParams, + CompletionItemKind, + InsertTextFormat, DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, @@ -35,6 +37,21 @@ MarkupKind, Range, Position, + TEXT_DOCUMENT_DEFINITION, + DefinitionParams, + Location, + TEXT_DOCUMENT_REFERENCES, + ReferenceParams, + TEXT_DOCUMENT_DOCUMENT_SYMBOL, + DocumentSymbolParams, + DocumentSymbol, + TEXT_DOCUMENT_SIGNATURE_HELP, + SignatureHelpParams, + SignatureHelp, + SignatureInformation, + ParameterInformation, + SignatureHelpOptions, + TextEdit, ) from functools import reduce from typing import Dict, List, Optional @@ -46,6 +63,8 @@ Token, ConceptInfo, ConceptLocation, + DatasourceInfo, + ImportInfo, ) from trilogy_language_server.parsing import ( tree_to_symbols, @@ -55,6 +74,13 @@ find_concept_at_position, format_concept_hover, resolve_concept_address, + get_definition_locations, + get_document_symbols, + extract_datasource_info, + extract_import_info, + format_datasource_hover, + format_import_hover, + TRILOGY_FUNCTIONS, ) from trilogy.parsing.render import Renderer from trilogy.parsing.parse_engine import ParseToObjects, PARSER @@ -85,6 +111,9 @@ def __init__(self) -> None: # Storage for concept hover information self.concept_locations: Dict[str, List[ConceptLocation]] = {} self.concept_info: Dict[str, Dict[str, ConceptInfo]] = {} + # Storage for datasource and import information + self.datasource_info: Dict[str, List[DatasourceInfo]] = {} + self.import_info: Dict[str, List[ImportInfo]] = {} def _validate( self: "TrilogyLanguageServer", @@ -131,6 +160,44 @@ def publish_concept_locations( ) self.concept_locations[uri] = [] + # Extract datasource information for hover tooltips + try: + datasources = extract_datasource_info(raw_tree) + self.datasource_info[uri] = datasources + self.window_log_message( + LogMessageParams( + type=MessageType.Log, + message=f"Found {len(datasources)} datasources for hover support", + ) + ) + except Exception as e: + self.window_log_message( + LogMessageParams( + type=MessageType.Warning, + message=f"Failed to extract datasource info: {e}", + ) + ) + self.datasource_info[uri] = [] + + # Extract import information for hover tooltips + try: + imports = extract_import_info(raw_tree) + self.import_info[uri] = imports + self.window_log_message( + LogMessageParams( + type=MessageType.Log, + message=f"Found {len(imports)} imports for hover support", + ) + ) + except Exception as e: + self.window_log_message( + LogMessageParams( + type=MessageType.Warning, + message=f"Failed to extract import info: {e}", + ) + ) + self.import_info[uri] = [] + def publish_code_lens( self: "TrilogyLanguageServer", original_text: str, raw_tree: ParseTree, uri: str ): @@ -183,7 +250,9 @@ def publish_code_lens( @trilogy_server.feature(TEXT_DOCUMENT_FORMATTING) -def format_document(ls: LanguageServer, params: DocumentFormattingParams): +def format_document( + ls: LanguageServer, params: DocumentFormattingParams +) -> Optional[List[TextEdit]]: """Format the entire document""" ls.window_log_message( LogMessageParams(type=MessageType.Log, message=f"Formatting called @ {params}") @@ -201,29 +270,184 @@ def format_document(ls: LanguageServer, params: DocumentFormattingParams): # For non-file URIs (e.g., untitled:), use default Environment env = Environment() - r = Renderer() - parser = ParseToObjects(environment=env) - parser.set_text(doc.source) - parser.prepare_parse() - parser.transform(PARSER.parse(doc.source)) - # this will reset fail on missing - pass_two = parser.run_second_parse_pass() - return "\n".join([r.to_string(v) for v in pass_two]) + try: + r = Renderer() + parser = ParseToObjects(environment=env) + parser.set_text(doc.source) + parser.prepare_parse() + parser.transform(PARSER.parse(doc.source)) + # this will reset fail on missing + pass_two = parser.run_second_parse_pass() + formatted_text = "\n".join([r.to_string(v) for v in pass_two]) + + # Calculate the range covering the entire document + lines = doc.source.split("\n") + last_line = len(lines) - 1 + last_char = len(lines[last_line]) if lines else 0 + + # Return a TextEdit that replaces the entire document + return [ + TextEdit( + range=Range( + start=Position(line=0, character=0), + end=Position(line=last_line, character=last_char), + ), + new_text=formatted_text, + ) + ] + except Exception as e: + ls.window_log_message( + LogMessageParams(type=MessageType.Error, message=f"Formatting failed: {e}") + ) + return None @trilogy_server.feature( - TEXT_DOCUMENT_COMPLETION, CompletionOptions(trigger_characters=[","]) + TEXT_DOCUMENT_COMPLETION, + CompletionOptions(trigger_characters=[",", ".", " "]), ) def completions(ls: TrilogyLanguageServer, params: Optional[CompletionParams] = None): """Returns completion items.""" - if params is not None: - ls.window_log_message( - LogMessageParams( - type=MessageType.Log, message=f"completion called @ {params.position}" - ) + if params is None: + return CompletionList(is_incomplete=False, items=[]) + + uri = params.text_document.uri + + ls.window_log_message( + LogMessageParams( + type=MessageType.Log, message=f"completion called @ {params.position}" ) + ) + items: t.List[CompletionItem] = [] - return CompletionList(False, items) + + # Get concept information from the document + concept_info_map = ls.concept_info.get(uri, {}) + + # Add concepts as completion items + for address, concept in concept_info_map.items(): + # Skip internal concepts + if concept.namespace == "__preql_internal": + continue + + # Determine icon based on purpose + kind = CompletionItemKind.Variable + if concept.purpose == "key": + kind = CompletionItemKind.Field + elif concept.purpose == "property": + kind = CompletionItemKind.Property + elif concept.purpose == "metric": + kind = CompletionItemKind.Value + elif concept.purpose == "constant": + kind = CompletionItemKind.Constant + + # Create documentation + doc_parts = [f"**{concept.purpose}** `{concept.name}`: `{concept.datatype}`"] + if concept.description: + doc_parts.append(concept.description) + if concept.lineage: + doc_parts.append( + f"Derivation: `{concept.lineage[:50]}...`" + if len(concept.lineage) > 50 + else f"Derivation: `{concept.lineage}`" + ) + + items.append( + CompletionItem( + label=concept.name, + kind=kind, + detail=f"{concept.purpose}: {concept.datatype}", + documentation=MarkupContent( + kind=MarkupKind.Markdown, + value="\n\n".join(doc_parts), + ), + insert_text=concept.name, + sort_text=f"0_{concept.name}", # Prioritize concepts + ) + ) + + # Add Trilogy keywords + keywords = [ + "select", + "key", + "property", + "metric", + "const", + "datasource", + "import", + "as", + "where", + "order", + "by", + "limit", + "asc", + "desc", + "and", + "or", + "not", + "in", + "between", + "like", + "is", + "null", + "true", + "false", + "grain", + "address", + "auto", + "persist", + "into", + "rowset", + "merge", + "show", + ] + + for keyword in keywords: + items.append( + CompletionItem( + label=keyword, + kind=CompletionItemKind.Keyword, + detail="keyword", + insert_text=keyword, + sort_text=f"1_{keyword}", # Keywords after concepts + ) + ) + + # Add Trilogy functions + for func_name, func_info in TRILOGY_FUNCTIONS.items(): + items.append( + CompletionItem( + label=func_name, + kind=CompletionItemKind.Function, + detail=func_info["signature"], + documentation=MarkupContent( + kind=MarkupKind.Markdown, + value=func_info["description"], + ), + insert_text=f"{func_name}($1)", + insert_text_format=InsertTextFormat.Snippet, + sort_text=f"2_{func_name}", # Functions after keywords + ) + ) + + # Add datasource names + datasources = ls.datasource_info.get(uri, []) + for ds in datasources: + items.append( + CompletionItem( + label=ds.name, + kind=CompletionItemKind.Struct, + detail=f"datasource -> {ds.address}", + documentation=MarkupContent( + kind=MarkupKind.Markdown, + value=f"**Datasource:** `{ds.name}`\n\n**Address:** `{ds.address}`", + ), + insert_text=ds.name, + sort_text=f"3_{ds.name}", + ) + ) + + return CompletionList(is_incomplete=False, items=items) @trilogy_server.feature(TEXT_DOCUMENT_DID_CHANGE) @@ -280,6 +504,8 @@ def hover(ls: TrilogyLanguageServer, params: HoverParams) -> Optional[Hover]: """Return hover information for the symbol at the given position.""" uri = params.text_document.uri position = params.position + line_1idx = position.line + 1 + col_1idx = position.character + 1 ls.window_log_message( LogMessageParams( @@ -288,6 +514,56 @@ def hover(ls: TrilogyLanguageServer, params: HoverParams) -> Optional[Hover]: ) ) + # Check if cursor is over a datasource + datasources = ls.datasource_info.get(uri, []) + for ds in datasources: + if ds.start_line <= line_1idx <= ds.end_line: + if ds.start_line == ds.end_line: + if ds.start_column <= col_1idx <= ds.end_column: + hover_text = format_datasource_hover(ds) + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, value=hover_text + ), + range=Range( + start=Position( + line=ds.start_line - 1, character=ds.start_column - 1 + ), + end=Position( + line=ds.end_line - 1, character=ds.end_column - 1 + ), + ), + ) + else: + hover_text = format_datasource_hover(ds) + return Hover( + contents=MarkupContent(kind=MarkupKind.Markdown, value=hover_text), + range=Range( + start=Position( + line=ds.start_line - 1, character=ds.start_column - 1 + ), + end=Position(line=ds.end_line - 1, character=ds.end_column - 1), + ), + ) + + # Check if cursor is over an import + imports = ls.import_info.get(uri, []) + for imp in imports: + if imp.start_line <= line_1idx <= imp.end_line: + if imp.start_column <= col_1idx <= imp.end_column: + hover_text = format_import_hover(imp) + return Hover( + contents=MarkupContent(kind=MarkupKind.Markdown, value=hover_text), + range=Range( + start=Position( + line=imp.start_line - 1, character=imp.start_column - 1 + ), + end=Position( + line=imp.end_line - 1, character=imp.end_column - 1 + ), + ), + ) + # Get concept locations for this document locations = ls.concept_locations.get(uri, []) if not locations: @@ -354,6 +630,253 @@ def hover(ls: TrilogyLanguageServer, params: HoverParams) -> Optional[Hover]: ) +@trilogy_server.feature(TEXT_DOCUMENT_DEFINITION) +def definition( + ls: TrilogyLanguageServer, params: DefinitionParams +) -> Optional[List[Location]]: + """Return the definition location for the symbol at the given position.""" + uri = params.text_document.uri + position = params.position + + ls.window_log_message( + LogMessageParams( + type=MessageType.Log, + message=f"Definition requested at line {position.line}, col {position.character}", + ) + ) + + # Get concept locations for this document + locations = ls.concept_locations.get(uri, []) + if not locations: + return None + + # Find if cursor is over a concept + location = find_concept_at_position(locations, position.line, position.character) + if not location: + return None + + # If already on a definition, return None (already at definition) + if location.is_definition: + return None + + # Get concept information to find the definition line + concept_info_map = ls.concept_info.get(uri, {}) + concept = resolve_concept_address(location.concept_address, concept_info_map) + + if concept and concept.line_number: + # Return the definition location + return [ + Location( + uri=uri, + range=Range( + start=Position( + line=concept.line_number - 1, + character=concept.column - 1 if concept.column else 0, + ), + end=Position( + line=( + concept.end_line - 1 + if concept.end_line + else concept.line_number - 1 + ), + character=concept.end_column - 1 if concept.end_column else 100, + ), + ), + ) + ] + + # Fall back to finding the definition in concept_locations + definition_locations = get_definition_locations(locations, location.concept_address) + if definition_locations: + return [ + Location( + uri=uri, + range=Range( + start=Position( + line=def_loc.start_line - 1, character=def_loc.start_column - 1 + ), + end=Position( + line=def_loc.end_line - 1, character=def_loc.end_column - 1 + ), + ), + ) + for def_loc in definition_locations + ] + + return None + + +@trilogy_server.feature(TEXT_DOCUMENT_REFERENCES) +def references( + ls: TrilogyLanguageServer, params: ReferenceParams +) -> Optional[List[Location]]: + """Return all references to the symbol at the given position.""" + uri = params.text_document.uri + position = params.position + + ls.window_log_message( + LogMessageParams( + type=MessageType.Log, + message=f"References requested at line {position.line}, col {position.character}", + ) + ) + + # Get concept locations for this document + locations = ls.concept_locations.get(uri, []) + if not locations: + return None + + # Find if cursor is over a concept + location = find_concept_at_position(locations, position.line, position.character) + if not location: + return None + + # Get concept info to resolve the full address + concept_info_map = ls.concept_info.get(uri, {}) + concept = resolve_concept_address(location.concept_address, concept_info_map) + target_address = concept.address if concept else location.concept_address + + # Find all locations that match this concept address + result_locations = [] + for loc in locations: + # Resolve the location's address to check for match + loc_concept = resolve_concept_address(loc.concept_address, concept_info_map) + loc_address = loc_concept.address if loc_concept else loc.concept_address + + if loc_address == target_address: + # Include definitions based on params.context.include_declaration + if loc.is_definition and not params.context.include_declaration: + continue + + result_locations.append( + Location( + uri=uri, + range=Range( + start=Position( + line=loc.start_line - 1, character=loc.start_column - 1 + ), + end=Position( + line=loc.end_line - 1, character=loc.end_column - 1 + ), + ), + ) + ) + + return result_locations if result_locations else None + + +@trilogy_server.feature(TEXT_DOCUMENT_DOCUMENT_SYMBOL) +def document_symbol( + ls: TrilogyLanguageServer, params: DocumentSymbolParams +) -> Optional[List[DocumentSymbol]]: + """Return document symbols for outline/navigation.""" + uri = params.text_document.uri + + ls.window_log_message( + LogMessageParams( + type=MessageType.Log, + message=f"Document symbols requested for {uri}", + ) + ) + + # Get concept locations for this document + locations = ls.concept_locations.get(uri, []) + concept_info_map = ls.concept_info.get(uri, {}) + datasources = ls.datasource_info.get(uri, []) + imports = ls.import_info.get(uri, []) + + return get_document_symbols(locations, concept_info_map, datasources, imports) + + +@trilogy_server.feature( + TEXT_DOCUMENT_SIGNATURE_HELP, + SignatureHelpOptions(trigger_characters=["(", ","]), +) +def signature_help( + ls: TrilogyLanguageServer, params: SignatureHelpParams +) -> Optional[SignatureHelp]: + """Return signature help for function calls.""" + uri = params.text_document.uri + position = params.position + + ls.window_log_message( + LogMessageParams( + type=MessageType.Log, + message=f"Signature help requested at line {position.line}, col {position.character}", + ) + ) + + # Get the document and current line content + doc = ls.workspace.get_text_document(uri) + lines = doc.source.split("\n") + + if position.line >= len(lines): + return None + + line = lines[position.line] + col = position.character + + # Find the function name by looking backwards from cursor + # Look for pattern like "function_name(" + text_before_cursor = line[:col] + + # Find the last open parenthesis and extract function name + paren_depth = 0 + func_start = -1 + for i in range(len(text_before_cursor) - 1, -1, -1): + char = text_before_cursor[i] + if char == ")": + paren_depth += 1 + elif char == "(": + if paren_depth == 0: + # Found the opening paren, now find function name + func_end = i + func_start = i - 1 + while func_start >= 0 and ( + text_before_cursor[func_start].isalnum() + or text_before_cursor[func_start] == "_" + ): + func_start -= 1 + func_start += 1 + func_name = text_before_cursor[func_start:func_end].strip() + + # Check if this is a known function + if func_name.lower() in TRILOGY_FUNCTIONS: + func_info = TRILOGY_FUNCTIONS[func_name.lower()] + + # Count commas to determine active parameter + text_after_paren = text_before_cursor[func_end + 1 :] + active_param = text_after_paren.count(",") + + return SignatureHelp( + signatures=[ + SignatureInformation( + label=func_info["signature"], + documentation=MarkupContent( + kind=MarkupKind.Markdown, + value=func_info["description"], + ), + parameters=[ + ParameterInformation( + label=param["name"], + documentation=param.get("description", ""), + ) + for param in func_info.get("parameters", []) + ], + ) + ], + active_signature=0, + active_parameter=min( + active_param, len(func_info.get("parameters", [])) - 1 + ), + ) + break + else: + paren_depth -= 1 + + return None + + @trilogy_server.feature(TEXT_DOCUMENT_CODE_LENS) def code_lens(ls: TrilogyLanguageServer, params: CodeLensParams): """Return a list of code lens to insert into the given document. diff --git a/trilogy_language_server/tests/test_ls_response.py b/trilogy_language_server/tests/test_ls_response.py index 95bb27e..7abd95a 100644 --- a/trilogy_language_server/tests/test_ls_response.py +++ b/trilogy_language_server/tests/test_ls_response.py @@ -36,6 +36,7 @@ DocumentFormattingParams, MessageType, HoverParams, + TextEdit, ) TEST_TEXT = """select 1-> test;""" @@ -134,6 +135,11 @@ def mock_server(self): server.workspace = Mock() server.window_log_message = Mock() server.window_show_message = Mock() + # Add required storage attributes + server.concept_info = {} + server.concept_locations = {} + server.datasource_info = {} + server.import_info = {} return server @pytest.fixture @@ -145,7 +151,7 @@ def mock_document(self): return doc def test_format_document(self, mock_server, mock_document): - """Test the format_document function.""" + """Test the format_document function returns List[TextEdit].""" # Setup mocks mock_server.workspace.get_text_document.return_value = mock_document @@ -162,13 +168,61 @@ def test_format_document(self, mock_server, mock_document): mock_server.workspace.get_text_document.assert_called_once_with( "file:///test/example.trilogy" ) - assert ( - result - == """SELECT + + # Result should be a list of TextEdit objects + assert result is not None + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextEdit) + + # The TextEdit should replace the entire document + assert result[0].range.start.line == 0 + assert result[0].range.start.character == 0 + + # The new_text should contain the formatted content + expected_text = """SELECT 1 -> test, ;""" + assert result[0].new_text == expected_text + + def test_format_document_returns_correct_range(self, mock_server): + """Test that format_document returns TextEdit with correct end range.""" + # Setup a multi-line document + mock_document = Mock() + mock_document.source = "SELECT 1 as a;\nSELECT 2 as b;" + mock_server.workspace.get_text_document.return_value = mock_document + + params = DocumentFormattingParams( + text_document=TextDocumentIdentifier(uri="file:///test/example.trilogy"), + options=Mock(), + ) + + result = format_document(mock_server, params) + + # Should return a TextEdit covering the entire document + assert result is not None + assert len(result) == 1 + # End line should be 1 (0-indexed, original has 2 lines) + assert result[0].range.end.line == 1 + # End character should be length of last line + assert result[0].range.end.character == len("SELECT 2 as b;") + + def test_format_document_handles_parse_error(self, mock_server): + """Test that format_document returns None on parse errors.""" + mock_document = Mock() + mock_document.source = "INVALID SYNTAX {{{{{" + mock_server.workspace.get_text_document.return_value = mock_document + + params = DocumentFormattingParams( + text_document=TextDocumentIdentifier(uri="file:///test/example.trilogy"), + options=Mock(), ) + result = format_document(mock_server, params) + + # Should return None on error + assert result is None + def test_completions_with_params(self, mock_server): """Test the completions function with parameters.""" params = CompletionParams( @@ -180,7 +234,13 @@ def test_completions_with_params(self, mock_server): mock_server.window_log_message.assert_called_once() assert result.is_incomplete is False - assert len(result.items) == 0 + # Should have keywords and functions in the completion list + assert len(result.items) > 0 + # Check that keywords are present + labels = [item.label for item in result.items] + assert "select" in labels + assert "key" in labels + assert "count" in labels # Function def test_completions_without_params(self, mock_server): """Test the completions function without parameters.""" @@ -272,6 +332,10 @@ def test_hover_with_concept(self, mock_server): """Test the hover function with concept information.""" uri = "file:///test/example.trilogy" + # Setup datasource and import info (empty for this test) + mock_server.datasource_info = {uri: []} + mock_server.import_info = {uri: []} + # Setup concept locations mock_server.concept_locations = { uri: [ @@ -316,6 +380,10 @@ def test_hover_no_concept_at_position(self, mock_server): """Test the hover function when no concept is at cursor position.""" uri = "file:///test/example.trilogy" + # Setup datasource and import info (empty for this test) + mock_server.datasource_info = {uri: []} + mock_server.import_info = {uri: []} + # Setup empty concept locations mock_server.concept_locations = {uri: []} @@ -420,10 +488,14 @@ def test_format_document_with_nested_import( # is now correctly set to the nested directory where base.preql exists result = format_document(mock_server, params) - # Verify formatting succeeded and contains the import statement + # Verify formatting succeeded and returns List[TextEdit] assert result is not None - assert "import base as base" in result - assert "base.x" in result + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextEdit) + # Check the formatted text contains the import statement + assert "import base as base" in result[0].new_text + assert "base.x" in result[0].new_text def test_format_document_with_nested_import_resolves_correctly( self, mock_server, nested_fixtures_path @@ -450,9 +522,13 @@ def test_format_document_with_nested_import_resolves_correctly( # This should succeed because base.preql is in the same directory result = format_document(mock_server, params) - # The formatted result should include the import and the selection - assert "import" in result - assert "SELECT" in result + # The formatted result should be a List[TextEdit] + assert result is not None + assert isinstance(result, list) + assert len(result) == 1 + # The formatted text should include the import and the selection + assert "import" in result[0].new_text + assert "SELECT" in result[0].new_text def test_format_document_with_invalid_uri_handles_gracefully(self, mock_server): """Test that format_document handles non-file URIs gracefully. @@ -473,9 +549,12 @@ def test_format_document_with_invalid_uri_handles_gracefully(self, mock_server): # This should not raise an error result = format_document(mock_server, params) - # Basic formatting should still work + # Basic formatting should still work and return List[TextEdit] assert result is not None - assert "SELECT" in result + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextEdit) + assert "SELECT" in result[0].new_text def test_format_document_preserves_import_when_wrong_working_path( self, mock_server @@ -504,6 +583,10 @@ def test_format_document_preserves_import_when_wrong_working_path( # With the fix, this should resolve the import correctly result = format_document(mock_server, params) + # Result should be List[TextEdit] assert result is not None + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextEdit) # The formatted output should contain the import - assert "import base as base" in result + assert "import base as base" in result[0].new_text diff --git a/trilogy_language_server/tests/test_parsing.py b/trilogy_language_server/tests/test_parsing.py index 53f029a..83c0de6 100644 --- a/trilogy_language_server/tests/test_parsing.py +++ b/trilogy_language_server/tests/test_parsing.py @@ -1,4 +1,9 @@ -from trilogy_language_server.models import Token, TokenModifier, ConceptInfo +from trilogy_language_server.models import ( + Token, + TokenModifier, + ConceptInfo, + ConceptLocation, +) from trilogy_language_server.parsing import ( tree_to_symbols, gen_tree, @@ -303,3 +308,208 @@ def test_format_concept_hover_with_lineage(): assert "**metric**" in hover_text assert "**Derivation:**" in hover_text assert "count" in hover_text + + +# Tests for new IDE features + + +def test_get_definition_locations(): + """Test finding definition locations for a concept""" + from trilogy_language_server.parsing import get_definition_locations + + locations = [ + ConceptLocation( + concept_address="local.user_id", + start_line=1, + start_column=5, + end_line=1, + end_column=12, + is_definition=True, + ), + ConceptLocation( + concept_address="local.user_id", + start_line=3, + start_column=8, + end_line=3, + end_column=15, + is_definition=False, + ), + ConceptLocation( + concept_address="local.name", + start_line=2, + start_column=10, + end_line=2, + end_column=14, + is_definition=True, + ), + ] + + # Find definition for user_id + defs = get_definition_locations(locations, "local.user_id") + assert len(defs) == 1 + assert defs[0].start_line == 1 + assert defs[0].is_definition is True + + # Find definition for non-existent concept + defs = get_definition_locations(locations, "local.nonexistent") + assert len(defs) == 0 + + +def test_extract_datasource_info(): + """Test extracting datasource information from parse tree""" + from trilogy_language_server.parsing import extract_datasource_info + + code = """key user_id int; + +datasource users ( + user_id +) +grain (user_id) +address users_table; +""" + tree = gen_tree(code) + datasources = extract_datasource_info(tree) + + # Should find the datasource + assert len(datasources) >= 1 + + +def test_extract_import_info(): + """Test extracting import information from parse tree""" + from trilogy_language_server.parsing import extract_import_info + + code = """import base as b; + +select b.user_id; +""" + tree = gen_tree(code) + imports = extract_import_info(tree) + + assert len(imports) >= 1 + + +def test_format_datasource_hover(): + """Test formatting datasource info for hover display""" + from trilogy_language_server.parsing import format_datasource_hover + from trilogy_language_server.models import DatasourceInfo + + ds = DatasourceInfo( + name="users", + address="users_table", + columns=["user_id", "name", "email"], + grain=["user_id"], + start_line=3, + start_column=1, + end_line=7, + end_column=20, + is_root=False, + ) + + hover_text = format_datasource_hover(ds) + + assert "**datasource**" in hover_text + assert "`users`" in hover_text + assert "`users_table`" in hover_text + assert "`user_id`" in hover_text + + +def test_format_import_hover(): + """Test formatting import info for hover display""" + from trilogy_language_server.parsing import format_import_hover + from trilogy_language_server.models import ImportInfo + + imp = ImportInfo( + path="base", + alias="b", + start_line=1, + start_column=1, + end_line=1, + end_column=18, + ) + + hover_text = format_import_hover(imp) + + assert "**import statement**" in hover_text + assert "`base`" in hover_text + assert "`b`" in hover_text + + +def test_get_document_symbols(): + """Test generating document symbols for outline""" + from trilogy_language_server.parsing import get_document_symbols + from trilogy_language_server.models import DatasourceInfo, ImportInfo + + locations = [ + ConceptLocation( + concept_address="local.user_id", + start_line=1, + start_column=5, + end_line=1, + end_column=12, + is_definition=True, + ), + ] + + concept_info_map = { + "local.user_id": ConceptInfo( + name="user_id", + address="local.user_id", + datatype="INTEGER", + purpose="key", + namespace="local", + line_number=1, + ) + } + + datasources = [ + DatasourceInfo( + name="users", + address="users_table", + columns=["user_id"], + grain=["user_id"], + start_line=3, + start_column=1, + end_line=7, + end_column=20, + is_root=False, + ) + ] + + imports = [ + ImportInfo( + path="base", + alias="b", + start_line=9, + start_column=1, + end_line=9, + end_column=18, + ) + ] + + symbols = get_document_symbols(locations, concept_info_map, datasources, imports) + + # Should have concept, datasource, and import symbols + assert len(symbols) == 3 + symbol_names = [s.name for s in symbols] + assert "user_id" in symbol_names + assert "users" in symbol_names + assert "b" in symbol_names + + +def test_trilogy_functions(): + """Test that TRILOGY_FUNCTIONS dictionary is properly populated""" + from trilogy_language_server.parsing import TRILOGY_FUNCTIONS + + # Check that common functions are present + assert "count" in TRILOGY_FUNCTIONS + assert "sum" in TRILOGY_FUNCTIONS + assert "avg" in TRILOGY_FUNCTIONS + assert "min" in TRILOGY_FUNCTIONS + assert "max" in TRILOGY_FUNCTIONS + + # Check structure + count_info = TRILOGY_FUNCTIONS["count"] + assert "signature" in count_info + assert "description" in count_info + assert "parameters" in count_info + assert len(count_info["parameters"]) > 0