diff --git a/src/cdl_lsp/constants.py b/src/cdl_lsp/constants.py index 7b5b75c..e58c1fa 100644 --- a/src/cdl_lsp/constants.py +++ b/src/cdl_lsp/constants.py @@ -25,10 +25,18 @@ from cdl_parser import ( TWIN_LAWS as _TWINS, ) + from cdl_parser import ( + FEATURE_NAMES as _FEAT_NAMES, + ) + from cdl_parser import ( + PHENOMENON_TYPES as _PHEN_TYPES, + ) CRYSTAL_SYSTEMS: set[str] = set(_SYSTEMS) NAMED_FORMS: dict[str, tuple[int, int, int]] = dict(_FORMS) TWIN_LAWS: set[str] = set(_TWINS) + FEATURE_NAMES: set[str] = set(_FEAT_NAMES) + PHENOMENON_TYPES: set[str] = set(_PHEN_TYPES) # Flatten all point groups from all systems ALL_POINT_GROUPS: set[str] = set() for _pgs in _GROUPS.values(): @@ -114,6 +122,24 @@ "gypsum_swallow", } + FEATURE_NAMES: set[str] = { + # Growth features + "phantom", "sector", "zoning", "skeletal", "dendritic", + # Surface features + "striation", "trigon", "etch_pit", "growth_hillock", + # Inclusion features + "inclusion", "needle", "silk", "fluid", "bubble", + # Color features + "colour", "colour_zone", "pleochroism", + # Other + "lamellar", "banding", + } + + PHENOMENON_TYPES: set[str] = { + "asterism", "chatoyancy", "adularescence", "labradorescence", + "play_of_color", "colour_change", "aventurescence", "iridescence", + } + # Default point group for each system DEFAULT_POINT_GROUPS: dict[str, str] = { "cubic": "m3m", @@ -129,7 +155,7 @@ # Modifications # ============================================================================= -MODIFICATIONS: set[str] = {"elongate", "truncate", "taper", "bevel", "twin"} +MODIFICATIONS: set[str] = {"elongate", "truncate", "taper", "bevel", "twin", "flatten"} # ============================================================================= # Common Miller indices by system @@ -344,6 +370,48 @@ - `twin(spinel)` - Spinel law macle - `twin(japan)` - Japan V-twin - `twin(trilling,3)` - Three-part cyclic twin""", + "flatten": """**flatten(axis:ratio)** + +Compresses the crystal along the specified axis. + +Parameters: +- axis: a, b, or c +- ratio: scaling factor (< 1 flattens) + +Example: `flatten(a:0.5)` - compress 50% along a-axis""", +} + +FEATURE_DOCS: dict[str, str] = { + "phantom": "**phantom** - Internal growth zones visible as ghost outlines.\n\nValues: count (int), color (str)\n\nExample: `[phantom:3, white]`", + "sector": "**sector** - Sector zoning patterns from differential growth rates.\n\nValues: type (hourglass, hexagonal)\n\nExample: `[sector:hourglass]`", + "zoning": "**zoning** - Color or composition banding.\n\nValues: pattern\n\nExample: `[zoning:concentric]`", + "skeletal": "**skeletal** - Skeletal/hopper growth from rapid crystallization.\n\nValues: ratio (0-1)\n\nExample: `[skeletal:0.4]`", + "dendritic": "**dendritic** - Branching tree-like growth.\n\nValues: density\n\nExample: `[dendritic:fine]`", + "striation": "**striation** - Linear surface markings from growth/twinning.\n\nValues: direction, count\n\nExample: `[striation:parallel, 5]`", + "trigon": "**trigon** - Triangular etch pits on diamond octahedron faces.\n\nValues: density (dense/sparse/moderate)\n\nExample: `[trigon:dense]`", + "etch_pit": "**etch_pit** - Dissolution features on crystal faces.\n\nValues: density\n\nExample: `[etch_pit:sparse]`", + "growth_hillock": "**growth_hillock** - Spiral growth features.\n\nValues: density\n\nExample: `[growth_hillock:moderate]`", + "inclusion": "**inclusion** - Solid mineral inclusions.\n\nValues: mineral name\n\nExample: `[inclusion:rutile]`", + "needle": "**needle** - Needle-like inclusions.\n\nValues: mineral, density (0-1)\n\nExample: `[needle:rutile, 0.3]`", + "silk": "**silk** - Fine needle networks causing optical effects.\n\nValues: pattern (dense/oriented/asterism)\n\nExample: `[silk:dense]`", + "fluid": "**fluid** - Fluid inclusions.\n\nValues: type (two-phase, three-phase)\n\nExample: `[fluid:three-phase]`", + "bubble": "**bubble** - Gas/fluid bubbles (diagnostic for synthetics).\n\nValues: type (spherical, elongated)\n\nExample: `[bubble:spherical]`", + "colour": "**colour** - Crystal color.\n\nValues: color name\n\nExample: `[colour:purple]`", + "colour_zone": "**colour_zone** - Color banding zones.\n\nValues: colors (dash-separated), count\n\nExample: `[colour_zone:pink-green-pink, 3]`", + "pleochroism": "**pleochroism** - Direction-dependent color variation.\n\nValues: colors\n\nExample: `[pleochroism:blue-violet]`", + "lamellar": "**lamellar** - Lamellar structures (e.g., in moonstone).\n\nValues: spacing\n\nExample: `[lamellar:fine]`", + "banding": "**banding** - Visible banding patterns.\n\nValues: type (agate, concentric)\n\nExample: `[banding:agate]`", +} + +PHENOMENON_DOCS: dict[str, str] = { + "asterism": "**asterism** - Star effect from oriented needle inclusions.\n\nParams: rays (3,4,6,12), intensity (weak/moderate/strong)\n\nExample: `| phenomenon[asterism:6, intensity:strong]`", + "chatoyancy": "**chatoyancy** - Cat's eye effect from parallel fibers.\n\nParams: sharpness (sharp/diffuse)\n\nExample: `| phenomenon[chatoyancy:sharp]`", + "adularescence": "**adularescence** - Floating blue-white light in moonstone.\n\nParams: intensity, color\n\nExample: `| phenomenon[adularescence:strong, blue]`", + "labradorescence": "**labradorescence** - Spectral color flash in labradorite.\n\nParams: colour\n\nExample: `| phenomenon[labradorescence:blue-green]`", + "play_of_color": "**play_of_color** - Spectral color patches in opal.\n\nParams: intensity (faint/moderate/intense)\n\nExample: `| phenomenon[play_of_color:intense]`", + "colour_change": "**colour_change** - Alexandrite effect (color change with lighting).\n\nParams: colours (dash-separated), intensity\n\nExample: `| phenomenon[colour_change:green-red, strong]`", + "aventurescence": "**aventurescence** - Sparkle effect from metallic inclusions.\n\nParams: colour\n\nExample: `| phenomenon[aventurescence:copper]`", + "iridescence": "**iridescence** - Rainbow colors from thin-film interference.\n\nExample: `| phenomenon[iridescence]`", } # ============================================================================= @@ -431,3 +499,13 @@ def is_valid_twin_law(name: str) -> bool: def is_valid_modification(name: str) -> bool: """Check if a name is a valid modification type.""" return name.lower() in MODIFICATIONS + + +def is_valid_feature_name(name: str) -> bool: + """Check if a name is a valid feature name.""" + return name.lower() in FEATURE_NAMES + + +def is_valid_phenomenon_type(name: str) -> bool: + """Check if a name is a valid phenomenon type.""" + return name.lower() in PHENOMENON_TYPES diff --git a/src/cdl_lsp/features/__init__.py b/src/cdl_lsp/features/__init__.py index 850ad0f..bdaa61b 100644 --- a/src/cdl_lsp/features/__init__.py +++ b/src/cdl_lsp/features/__init__.py @@ -17,7 +17,7 @@ from .code_actions import get_code_action_kinds, get_code_actions from .completion import get_completions -from .definition import get_definition +from .definition import find_document_definitions, get_definition from .diagnostics import DiagnosticInfo, get_diagnostics, validate_document from .document_symbols import get_document_symbols from .explain import explain_cdl, get_explain_result @@ -38,6 +38,7 @@ "get_hover_info", # Definition "get_definition", + "find_document_definitions", # Snippets "get_preset_snippets", "get_snippet_for_preset", diff --git a/src/cdl_lsp/features/completion.py b/src/cdl_lsp/features/completion.py index 3593c6f..f4f988f 100644 --- a/src/cdl_lsp/features/completion.py +++ b/src/cdl_lsp/features/completion.py @@ -19,10 +19,14 @@ COMMON_SCALES, CRYSTAL_SYSTEMS, DEFAULT_POINT_GROUPS, + FEATURE_DOCS, + FEATURE_NAMES, FORM_DOCS, MODIFICATION_DOCS, MODIFICATIONS, NAMED_FORMS, + PHENOMENON_DOCS, + PHENOMENON_TYPES, POINT_GROUP_DOCS, POINT_GROUPS, SYSTEM_DOCS, @@ -47,6 +51,10 @@ class CompletionContext(Enum): MODIFICATION = auto() # Inside modification name MODIFICATION_PARAM = auto() # Inside modification parameters TWIN_LAW = auto() # Inside twin() parameters + FEATURE_NAME = auto() # Inside [...] after form, expecting feature name + PHENOMENON_TYPE = auto() # After | phenomenon[, expecting type + REFERENCE = auto() # After $ expecting definition name + DEFINITION_START = auto() # After @ at line start, expecting definition syntax UNKNOWN = auto() @@ -72,17 +80,43 @@ def _detect_context(line: str, col: int) -> tuple[CompletionContext, str]: if not text_before_stripped: return (CompletionContext.EMPTY, current_word) + # After $ - reference to a named definition + ref_match = re.search(r"\$(\w*)$", text_before) + if ref_match: + return (CompletionContext.REFERENCE, ref_match.group(1)) + + # @ at line start - definition syntax + def_match = re.match(r"^\s*@(\w*)$", text_before) + if def_match: + return (CompletionContext.DEFINITION_START, def_match.group(1)) + # Inside twin() twin_match = re.search(r"twin\s*\(\s*(\w*)$", text_before, re.IGNORECASE) if twin_match: return (CompletionContext.TWIN_LAW, twin_match.group(1)) # Inside modification parameters - for mod in ["elongate", "truncate", "taper", "bevel"]: + for mod in ["elongate", "truncate", "taper", "bevel", "flatten"]: mod_match = re.search(rf"{mod}\s*\([^)]*$", text_before, re.IGNORECASE) if mod_match: return (CompletionContext.MODIFICATION_PARAM, current_word) + # After | phenomenon[ + phenomenon_match = re.search(r"phenomenon\s*\[\s*(\w*)$", text_before, re.IGNORECASE) + if phenomenon_match: + return (CompletionContext.PHENOMENON_TYPE, phenomenon_match.group(1)) + + # Inside feature brackets [...] after form + # Check if we're after }@scale[ or }[ + feature_bracket = re.search(r"\}[^{]*\[[^\]]*$", text_before) + if feature_bracket: + inside = re.search(r"\[([^\]]*)$", text_before) + if inside: + content = inside.group(1) + # If the last item has no colon, we're typing a feature name + if ":" not in content.split(",")[-1]: + return (CompletionContext.FEATURE_NAME, current_word) + # Inside Miller index {} brace_match = re.search(r"\{[^}]*$", text_before) if brace_match: @@ -174,7 +208,30 @@ def _create_completion_item( ) -def get_completions(line: str, col: int, trigger_character: str | None = None) -> list[Any]: +def _find_definitions_in_text(document_text: str) -> list[tuple[str, str]]: + """ + Find all @name = expression definitions in document text. + + Args: + document_text: Full document text + + Returns: + List of (name, expression) tuples + """ + definitions = [] + for doc_line in document_text.split("\n"): + match = re.match(r"^\s*@(\w+)\s*=\s*(.+)$", doc_line) + if match: + definitions.append((match.group(1), match.group(2).strip())) + return definitions + + +def get_completions( + line: str, + col: int, + trigger_character: str | None = None, + document_text: str = "", +) -> list[Any]: """ Get completion items for the current position. @@ -182,6 +239,7 @@ def get_completions(line: str, col: int, trigger_character: str | None = None) - line: Current line text col: Column position (0-based) trigger_character: The character that triggered completion (if any) + document_text: Full document text (for scanning definitions) Returns: List of completion items @@ -327,7 +385,7 @@ def get_completions(line: str, col: int, trigger_character: str | None = None) - ) elif context == CompletionContext.AFTER_PIPE: - # Suggest modifications and twin + # Suggest modifications, twin, and phenomenon prefix = current_word.lower() for mod in sorted(MODIFICATIONS): @@ -343,6 +401,18 @@ def get_completions(line: str, col: int, trigger_character: str | None = None) - ) ) + # Add phenomenon as an option after pipe + if "phenomenon".startswith(prefix) or not prefix: + items.append( + _create_completion_item( + label="phenomenon", + kind=kind.Function if kind else "Function", + detail="Optical phenomenon", + documentation="Specify an optical phenomenon like asterism, chatoyancy, etc.", + insert_text="phenomenon[", + ) + ) + elif context == CompletionContext.TWIN_LAW: # Suggest twin laws prefix = current_word.lower() @@ -360,10 +430,40 @@ def get_completions(line: str, col: int, trigger_character: str | None = None) - ) ) + elif context == CompletionContext.FEATURE_NAME: + prefix = current_word.lower() + for feat in sorted(FEATURE_NAMES): + if feat.startswith(prefix) or not prefix: + doc = FEATURE_DOCS.get(feat, "") + items.append( + _create_completion_item( + label=feat, + kind=kind.Property if kind else "Property", + detail="Feature", + documentation=doc, + insert_text=f"{feat}:", + ) + ) + + elif context == CompletionContext.PHENOMENON_TYPE: + prefix = current_word.lower() + for phen in sorted(PHENOMENON_TYPES): + if phen.startswith(prefix) or not prefix: + doc = PHENOMENON_DOCS.get(phen, "") + items.append( + _create_completion_item( + label=phen, + kind=kind.EnumMember if kind else "EnumMember", + detail="Optical phenomenon", + documentation=doc, + insert_text=f"{phen}:", + ) + ) + elif context == CompletionContext.MODIFICATION_PARAM: # Context-sensitive parameter completions # Detect which modification we're in - for mod in ["elongate", "truncate", "taper", "bevel"]: + for mod in ["elongate", "truncate", "taper", "bevel", "flatten"]: if re.search(rf"{mod}\s*\(", line, re.IGNORECASE): if mod == "elongate": for axis in ["a", "b", "c"]: @@ -386,6 +486,48 @@ def get_completions(line: str, col: int, trigger_character: str | None = None) - detail=f"{{{miller[0]}{miller[1]}{miller[2]}}}", ) ) + elif mod == "flatten": + for axis in ["a", "b", "c"]: + items.append( + _create_completion_item( + label=f"{axis}:", + kind=kind.Property if kind else "Property", + detail="Axis parameter", + documentation=f"Flatten along {axis}-axis", + ) + ) break + elif context == CompletionContext.REFERENCE: + # After $, suggest known definition names from the document + prefix = current_word.lower() + definitions = _find_definitions_in_text(document_text) + for def_name, def_expr in definitions: + if def_name.lower().startswith(prefix) or not prefix: + items.append( + _create_completion_item( + label=def_name, + kind=kind.Variable if kind else "Variable", + detail=f"Reference to @{def_name}", + documentation=f"Defined as: `{def_expr}`", + sort_text="0" + def_name, + ) + ) + + elif context == CompletionContext.DEFINITION_START: + # After @ at line start, suggest definition syntax template + items.append( + _create_completion_item( + label="name = expression", + kind=kind.Snippet if kind else "Snippet", + detail="Named definition", + documentation=( + "Define a named CDL expression that can be referenced with `$name`.\n\n" + "Example:\n```\n@prism = {10-10}@1.0\n@body = $prism + {10-11}@0.8\n```" + ), + insert_text="name = ", + sort_text="0", + ) + ) + return items diff --git a/src/cdl_lsp/features/definition.py b/src/cdl_lsp/features/definition.py index 266ff62..1f1dacd 100644 --- a/src/cdl_lsp/features/definition.py +++ b/src/cdl_lsp/features/definition.py @@ -2,7 +2,8 @@ CDL Definition - Go to definition support for CDL documents. This module provides go-to-definition functionality for CDL elements, -allowing navigation to the source definitions of forms, twin laws, etc. +allowing navigation to the source definitions of forms, twin laws, +and named CDL definitions (@name = expression / $name references). """ import os @@ -23,6 +24,11 @@ get_definition_source, ) +# Pattern for named definitions: @name = expression +DEFINITION_LINE_PATTERN = re.compile(r"^@(\w+)\s*=\s*(.+)$") +# Pattern for references: $name +REFERENCE_PATTERN = re.compile(r"\$(\w+)") + def _get_word_at_position(line: str, col: int) -> tuple[str, int, int]: """ @@ -134,7 +140,92 @@ def _create_location(file_path: str, line: int, character: int = 0) -> Any: ) -def get_definition(line: str, col: int, line_num: int = 0, document_uri: str = "") -> Any | None: +def _find_definition_in_document( + name: str, document_text: str, document_uri: str +) -> Any | None: + """ + Find a @name definition in the document text. + + Args: + name: The definition name (without $ or @) + document_text: Full document text + document_uri: URI of the document + + Returns: + Location object or None + """ + lines = document_text.split("\n") + for i, doc_line in enumerate(lines): + match = DEFINITION_LINE_PATTERN.match(doc_line.strip()) + if match and match.group(1) == name: + # Find the column of the @name + stripped = doc_line.lstrip() + col_offset = len(doc_line) - len(stripped) + # Point to the name after @ + name_col = col_offset + 1 # skip @ + + if types is None: + return { + "uri": document_uri, + "range": { + "start": {"line": i, "character": name_col}, + "end": {"line": i, "character": name_col + len(name)}, + }, + } + + return types.Location( + uri=document_uri, + range=types.Range( + start=types.Position(line=i, character=name_col), + end=types.Position(line=i, character=name_col + len(name)), + ), + ) + return None + + +def _is_on_reference(line: str, col: int) -> str | None: + """ + Check if the cursor is on a $reference and return the name. + + Args: + line: Line text + col: Column position (0-based) + + Returns: + Reference name (without $) or None + """ + for match in REFERENCE_PATTERN.finditer(line): + # Match spans from $ to end of name + if match.start() <= col <= match.end(): + return match.group(1) + return None + + +def find_document_definitions(document_text: str) -> list[tuple[str, int, str]]: + """ + Find all @name = expression definitions in a document. + + Args: + document_text: Full document text + + Returns: + List of (name, line_number, expression) tuples + """ + definitions = [] + for i, line in enumerate(document_text.split("\n")): + match = DEFINITION_LINE_PATTERN.match(line.strip()) + if match: + definitions.append((match.group(1), i, match.group(2))) + return definitions + + +def get_definition( + line: str, + col: int, + line_num: int = 0, + document_uri: str = "", + document_text: str = "", +) -> Any | None: """ Get definition location for the symbol at position. @@ -143,10 +234,18 @@ def get_definition(line: str, col: int, line_num: int = 0, document_uri: str = " col: Column position (0-based) line_num: Line number (0-based) document_uri: URI of the current document + document_text: Full document text (needed for $reference resolution) Returns: Location object or None if no definition found """ + # Check if cursor is on a $reference + ref_name = _is_on_reference(line, col) + if ref_name and document_text: + result = _find_definition_in_document(ref_name, document_text, document_uri) + if result: + return result + word, start, end = _get_word_at_position(line, col) if not word: @@ -196,7 +295,13 @@ def get_definition(line: str, col: int, line_num: int = 0, document_uri: str = " return None -def get_definitions(line: str, col: int, line_num: int = 0, document_uri: str = "") -> list[Any]: +def get_definitions( + line: str, + col: int, + line_num: int = 0, + document_uri: str = "", + document_text: str = "", +) -> list[Any]: """ Get all definition locations for the symbol at position. @@ -208,11 +313,12 @@ def get_definitions(line: str, col: int, line_num: int = 0, document_uri: str = col: Column position (0-based) line_num: Line number (0-based) document_uri: URI of the current document + document_text: Full document text (needed for $reference resolution) Returns: List of Location objects """ - definition = get_definition(line, col, line_num, document_uri) + definition = get_definition(line, col, line_num, document_uri, document_text) if definition: return [definition] return [] diff --git a/src/cdl_lsp/features/diagnostics.py b/src/cdl_lsp/features/diagnostics.py index 1d7df39..4010580 100644 --- a/src/cdl_lsp/features/diagnostics.py +++ b/src/cdl_lsp/features/diagnostics.py @@ -27,6 +27,8 @@ from ..constants import ( ALL_POINT_GROUPS, CRYSTAL_SYSTEMS, + FEATURE_NAMES, + PHENOMENON_TYPES, POINT_GROUPS, TWIN_LAWS, validate_point_group_for_system, @@ -175,6 +177,10 @@ def _validate_cdl_line(line_text: str, line_num: int) -> list[DiagnosticInfo]: _check_form_typos(line_text, line_num, pre_parse_diagnostics) _check_twin_typos(line_text, line_num, pre_parse_diagnostics) + # Check for invalid feature names and phenomenon types (warnings, not blocking) + _check_feature_names(line_text, line_num, diagnostics) + _check_phenomenon_type(line_text, line_num, diagnostics) + # If we found issues with quick-fix data, return those if pre_parse_diagnostics: return pre_parse_diagnostics @@ -486,6 +492,58 @@ def _check_modification_typos( break # Only report first occurrence +def _check_feature_names( + line_text: str, line_num: int, diagnostics: list[DiagnosticInfo] +) -> None: + """Check for invalid feature names in [...] blocks after forms.""" + # Find feature brackets: }[...] or }@scale[...] + for match in re.finditer(r"\}(?:@[\d.]+)?\[([^\]]+)\]", line_text): + content = match.group(1) + # Parse individual features (name:value pairs) + for feat_match in re.finditer(r"(\w+)\s*:", content): + feat_name = feat_match.group(1).lower() + # Skip if it looks like a point group context (numbers, slashes) + if feat_name in ALL_POINT_GROUPS: + continue + if feat_name not in FEATURE_NAMES: + abs_start = match.start(1) + feat_match.start(1) + diagnostics.append( + DiagnosticInfo( + line=line_num, + start_char=abs_start, + end_char=abs_start + len(feat_name), + message=f"Unknown feature '{feat_name}'. " + f"Known features: {', '.join(sorted(FEATURE_NAMES))}", + severity="warning", + code="unknown-feature", + data={"original": feat_name}, + ) + ) + + +def _check_phenomenon_type( + line_text: str, line_num: int, diagnostics: list[DiagnosticInfo] +) -> None: + """Check for invalid phenomenon types.""" + phen_match = re.search(r"phenomenon\[(\w+)", line_text) + if phen_match: + phen_type = phen_match.group(1).lower() + if phen_type not in PHENOMENON_TYPES: + start = phen_match.start(1) + diagnostics.append( + DiagnosticInfo( + line=line_num, + start_char=start, + end_char=start + len(phen_type), + message=f"Unknown phenomenon type '{phen_type}'. " + f"Known types: {', '.join(sorted(PHENOMENON_TYPES))}", + severity="warning", + code="unknown-phenomenon", + data={"original": phen_type}, + ) + ) + + def get_diagnostics(text: str) -> list[Any]: """ Get LSP Diagnostic objects for a CDL document. diff --git a/src/cdl_lsp/features/document_symbols.py b/src/cdl_lsp/features/document_symbols.py index 1b70e0b..eaa808b 100644 --- a/src/cdl_lsp/features/document_symbols.py +++ b/src/cdl_lsp/features/document_symbols.py @@ -2,7 +2,8 @@ CDL Document Symbols - Outline view for CDL documents. This module provides document symbols that create an outline view -showing crystal definitions, forms, and modifications in the sidebar. +showing crystal definitions, forms, modifications, and named +definitions (@name = expression) in the sidebar. """ import re @@ -13,12 +14,16 @@ except ImportError: types = None +# Pattern for named definitions: @name = expression +DEFINITION_LINE_PATTERN = re.compile(r"^@(\w+)\s*=\s*(.+)$") + def get_document_symbols(text: str) -> list[Any]: """ Extract symbols from a CDL document. Symbols include: + - Named definitions (@name = expression) - Crystal definitions (system[point_group]) - Forms ({hkl} or named forms) - Modifications (twin, elongate, etc.) @@ -42,6 +47,12 @@ def get_document_symbols(text: str) -> list[Any]: if not line_stripped or line_stripped.startswith("#"): continue + # Check for named definition (@name = expression) + def_symbol = _parse_definition_line(line, line_stripped, line_num) + if def_symbol: + symbols.append(def_symbol) + continue + # Parse the CDL line symbol = _parse_cdl_line(line, line_stripped, line_num) if symbol: @@ -50,6 +61,52 @@ def get_document_symbols(text: str) -> list[Any]: return symbols +def _parse_definition_line(original_line: str, line: str, line_num: int) -> Any | None: + """ + Parse a named definition line (@name = expression) into a DocumentSymbol. + + Args: + original_line: Original line with leading whitespace + line: Stripped line content + line_num: Line number (0-based) + + Returns: + DocumentSymbol or None + """ + if types is None: + return None + + match = DEFINITION_LINE_PATTERN.match(line) + if not match: + return None + + name = match.group(1) + expression = match.group(2) + + start_col = len(original_line) - len(original_line.lstrip()) + + # Full line range + line_range = types.Range( + start=types.Position(line=line_num, character=start_col), + end=types.Position(line=line_num, character=len(original_line)), + ) + + # Selection range is just the name part (after @) + name_start = start_col + 1 # skip @ + selection_range = types.Range( + start=types.Position(line=line_num, character=name_start), + end=types.Position(line=line_num, character=name_start + len(name)), + ) + + return types.DocumentSymbol( + name=name, + kind=types.SymbolKind.Variable, + range=line_range, + selection_range=selection_range, + detail=f"Definition: {expression[:50]}{'...' if len(expression) > 50 else ''}", + ) + + def _parse_cdl_line(original_line: str, line: str, line_num: int) -> Any | None: """ Parse a CDL line and create a DocumentSymbol. diff --git a/src/cdl_lsp/features/formatting.py b/src/cdl_lsp/features/formatting.py index 78984bd..ef59976 100644 --- a/src/cdl_lsp/features/formatting.py +++ b/src/cdl_lsp/features/formatting.py @@ -80,6 +80,15 @@ def format_line(line: str) -> str: if content.strip().startswith("#"): return line + # Handle definition lines: @name = expression + def_match = re.match(r"^(@\w+)\s*=\s*(.+)$", content.strip()) + if def_match: + def_name = def_match.group(1) + def_expr = def_match.group(2) + # Format the expression part + formatted_expr = format_line(def_expr) + return f"{leading_ws}{def_name} = {formatted_expr}" + formatted = content # 1. Lowercase crystal system names diff --git a/src/cdl_lsp/server.py b/src/cdl_lsp/server.py index e3adebc..46add07 100644 --- a/src/cdl_lsp/server.py +++ b/src/cdl_lsp/server.py @@ -74,7 +74,7 @@ def initialize(params: types.InitializeParams) -> types.InitializeResult: save=types.SaveOptions(include_text=True), ), completion_provider=types.CompletionOptions( - trigger_characters=["{", "[", ":", "@", "+", "|", "(", ","], + trigger_characters=["{", "[", ":", "@", "+", "|", "(", ",", "$"], resolve_provider=False, ), hover_provider=types.HoverOptions(), @@ -200,7 +200,7 @@ def completion(params: types.CompletionParams) -> types.CompletionList | None: if params.context and params.context.trigger_character: trigger_char = params.context.trigger_character - items = get_completions(line, col, trigger_char) + items = get_completions(line, col, trigger_char, document_text=text) return types.CompletionList(is_incomplete=False, items=items) @@ -252,7 +252,7 @@ def definition(params: types.DefinitionParams) -> types.Location | None: logger.debug(f"Definition at {uri}:{position.line}:{col}") - return get_definition(line, col, position.line, uri) + return get_definition(line, col, position.line, uri, text) # ========================================================================== # Diagnostics (Pull Model) diff --git a/tests/test_definitions_and_references.py b/tests/test_definitions_and_references.py new file mode 100644 index 0000000..71deb3c --- /dev/null +++ b/tests/test_definitions_and_references.py @@ -0,0 +1,403 @@ +""" +Test suite for CDL named definitions and $references. + +Tests go-to-definition for $references, document symbols for @definitions, +and completion support for $ and @ contexts. +""" + +from cdl_lsp.features.completion import ( + CompletionContext, + _detect_context, + _find_definitions_in_text, + get_completions, +) +from cdl_lsp.features.definition import ( + _is_on_reference, + find_document_definitions, + get_definition, + get_definitions, +) +from cdl_lsp.features.document_symbols import get_document_symbols +from cdl_lsp.features.formatting import format_line + + +# ============================================================================ +# Sample document with definitions and references +# ============================================================================ + +SAMPLE_DOC = """\ +# Crystal definitions +@prism = {10-10}@1.0 +@rhomb = {10-11}@0.8 +@body = $prism + $rhomb + +trigonal[32]:$body | elongate(c:2.0) +""" + +SAMPLE_DOC_URI = "file:///test/sample.cdl" + + +# ============================================================================ +# Go-to-definition for $references +# ============================================================================ + + +class TestIsOnReference: + """Test detecting $reference at cursor position.""" + + def test_cursor_on_dollar_sign(self): + """Cursor on $ detects reference.""" + name = _is_on_reference("$prism + $rhomb", 0) + assert name == "prism" + + def test_cursor_inside_reference_name(self): + """Cursor inside reference name.""" + name = _is_on_reference("$prism + $rhomb", 3) + assert name == "prism" + + def test_cursor_at_end_of_reference(self): + """Cursor at end of reference name.""" + name = _is_on_reference("$prism + $rhomb", 6) + assert name == "prism" + + def test_cursor_on_second_reference(self): + """Cursor on second reference.""" + name = _is_on_reference("$prism + $rhomb", 10) + assert name == "rhomb" + + def test_cursor_not_on_reference(self): + """Cursor not on any reference.""" + name = _is_on_reference("cubic[m3m]:{111}", 5) + assert name is None + + def test_cursor_on_space_between_refs(self): + """Cursor on space between references.""" + name = _is_on_reference("$prism + $rhomb", 7) + assert name is None + + def test_reference_in_cdl_line(self): + """Reference embedded in CDL line.""" + name = _is_on_reference("trigonal[32]:$body | elongate(c:2.0)", 14) + assert name == "body" + + +class TestFindDocumentDefinitions: + """Test finding @definitions in document text.""" + + def test_find_all_definitions(self): + """Find all definitions in document.""" + defs = find_document_definitions(SAMPLE_DOC) + names = [d[0] for d in defs] + assert "prism" in names + assert "rhomb" in names + assert "body" in names + + def test_definition_line_numbers(self): + """Verify definition line numbers are correct.""" + defs = find_document_definitions(SAMPLE_DOC) + # Line 0 is comment, line 1 is @prism, line 2 is @rhomb, line 3 is @body + name_to_line = {d[0]: d[1] for d in defs} + assert name_to_line["prism"] == 1 + assert name_to_line["rhomb"] == 2 + assert name_to_line["body"] == 3 + + def test_definition_expressions(self): + """Verify definition expressions are captured.""" + defs = find_document_definitions(SAMPLE_DOC) + name_to_expr = {d[0]: d[2] for d in defs} + assert name_to_expr["prism"] == "{10-10}@1.0" + assert name_to_expr["rhomb"] == "{10-11}@0.8" + assert name_to_expr["body"] == "$prism + $rhomb" + + def test_no_definitions_in_regular_cdl(self): + """Regular CDL without definitions returns empty.""" + defs = find_document_definitions("cubic[m3m]:{111}@1.0") + assert defs == [] + + def test_empty_document(self): + """Empty document returns empty.""" + defs = find_document_definitions("") + assert defs == [] + + +class TestGoToDefinitionForReferences: + """Test go-to-definition with $references.""" + + def test_reference_resolves_to_definition(self): + """$prism reference resolves to @prism definition line.""" + # Line "@body = $prism + $rhomb" is line 3 of SAMPLE_DOC + line = "@body = $prism + $rhomb" + result = get_definition( + line, 9, line_num=3, document_uri=SAMPLE_DOC_URI, document_text=SAMPLE_DOC + ) + assert result is not None + # Should point to line 1 where @prism is defined + if hasattr(result, "range"): + assert result.range.start.line == 1 + else: + assert result["range"]["start"]["line"] == 1 + + def test_second_reference_resolves(self): + """$rhomb reference resolves to @rhomb definition.""" + line = "@body = $prism + $rhomb" + result = get_definition( + line, 18, line_num=3, document_uri=SAMPLE_DOC_URI, document_text=SAMPLE_DOC + ) + assert result is not None + if hasattr(result, "range"): + assert result.range.start.line == 2 + else: + assert result["range"]["start"]["line"] == 2 + + def test_reference_in_cdl_line_resolves(self): + """$body reference in CDL line resolves to @body definition.""" + line = "trigonal[32]:$body | elongate(c:2.0)" + result = get_definition( + line, 14, line_num=5, document_uri=SAMPLE_DOC_URI, document_text=SAMPLE_DOC + ) + assert result is not None + if hasattr(result, "range"): + assert result.range.start.line == 3 + else: + assert result["range"]["start"]["line"] == 3 + + def test_undefined_reference_returns_none(self): + """$unknown reference returns None.""" + line = "trigonal[32]:$unknown" + result = get_definition( + line, 14, line_num=0, document_uri=SAMPLE_DOC_URI, document_text=SAMPLE_DOC + ) + assert result is None + + def test_no_document_text_skips_reference(self): + """Without document_text, $ref lookup is skipped gracefully.""" + line = "trigonal[32]:$body" + result = get_definition(line, 14, line_num=0, document_uri=SAMPLE_DOC_URI) + # Without document_text, can't resolve reference + assert result is None + + def test_get_definitions_for_reference(self): + """get_definitions returns list with reference resolution.""" + line = "@body = $prism + $rhomb" + result = get_definitions( + line, 9, line_num=3, document_uri=SAMPLE_DOC_URI, document_text=SAMPLE_DOC + ) + assert isinstance(result, list) + assert len(result) == 1 + + +# ============================================================================ +# Document symbols for @definitions +# ============================================================================ + + +class TestDocumentSymbolsForDefinitions: + """Test document symbols include @definitions.""" + + def test_definitions_appear_as_symbols(self): + """@definitions appear as Variable symbols.""" + symbols = get_document_symbols(SAMPLE_DOC) + if not symbols: + # lsprotocol not available, skip + return + names = [s.name for s in symbols] + assert "prism" in names + assert "rhomb" in names + assert "body" in names + + def test_definition_symbol_kind(self): + """@definitions have Variable symbol kind.""" + symbols = get_document_symbols(SAMPLE_DOC) + if not symbols: + return + from lsprotocol import types + + def_symbols = [s for s in symbols if s.name in ("prism", "rhomb", "body")] + for sym in def_symbols: + assert sym.kind == types.SymbolKind.Variable + + def test_definition_symbol_detail(self): + """@definitions have expression in detail.""" + symbols = get_document_symbols(SAMPLE_DOC) + if not symbols: + return + prism_sym = next(s for s in symbols if s.name == "prism") + assert "Definition:" in prism_sym.detail + assert "{10-10}" in prism_sym.detail + + def test_mixed_definitions_and_cdl(self): + """Both definitions and CDL lines appear as symbols.""" + symbols = get_document_symbols(SAMPLE_DOC) + if not symbols: + return + names = [s.name for s in symbols] + # Should have definition symbols + CDL line symbol + assert "prism" in names # @prism definition + assert any("trigonal" in n for n in names) # CDL line + + def test_definition_selection_range(self): + """Selection range covers just the name, not the @.""" + symbols = get_document_symbols(SAMPLE_DOC) + if not symbols: + return + prism_sym = next(s for s in symbols if s.name == "prism") + # Name starts at column 1 (after @), line 1 + assert prism_sym.selection_range.start.line == 1 + assert prism_sym.selection_range.start.character == 1 + assert prism_sym.selection_range.end.character == 1 + len("prism") + + def test_no_definitions_only_cdl(self): + """Document with only CDL lines shows no Variable symbols.""" + doc = "cubic[m3m]:{111}@1.0 + {100}@1.3" + symbols = get_document_symbols(doc) + if not symbols: + return + from lsprotocol import types + + var_symbols = [s for s in symbols if s.kind == types.SymbolKind.Variable] + assert len(var_symbols) == 0 + + +# ============================================================================ +# Completion for $references and @definitions +# ============================================================================ + + +class TestDetectReferenceContext: + """Test context detection for $ and @ syntax.""" + + def test_dollar_sign_triggers_reference(self): + """$ at cursor triggers REFERENCE context.""" + ctx, word = _detect_context("$", 1) + assert ctx == CompletionContext.REFERENCE + assert word == "" + + def test_dollar_with_partial_name(self): + """$pr triggers REFERENCE context with prefix.""" + ctx, word = _detect_context("$pr", 3) + assert ctx == CompletionContext.REFERENCE + assert word == "pr" + + def test_dollar_in_expression(self): + """$ inside expression triggers REFERENCE.""" + ctx, word = _detect_context("cubic[m3m]:$", 12) + assert ctx == CompletionContext.REFERENCE + assert word == "" + + def test_dollar_after_plus(self): + """$ after + triggers REFERENCE.""" + ctx, word = _detect_context("{111}@1.0 + $p", 14) + assert ctx == CompletionContext.REFERENCE + assert word == "p" + + def test_at_at_line_start(self): + """@ at line start triggers DEFINITION_START.""" + ctx, word = _detect_context("@", 1) + assert ctx == CompletionContext.DEFINITION_START + assert word == "" + + def test_at_with_partial_name(self): + """@pr at line start triggers DEFINITION_START.""" + ctx, word = _detect_context("@pr", 3) + assert ctx == CompletionContext.DEFINITION_START + assert word == "pr" + + def test_at_not_at_line_start_is_scale(self): + """@ after } is scale context, not definition.""" + ctx, word = _detect_context("cubic[m3m]:{111}@", 17) + assert ctx == CompletionContext.AFTER_AT + + +class TestFindDefinitionsInText: + """Test finding definitions for completion.""" + + def test_find_definitions(self): + """Find all definitions in document text.""" + defs = _find_definitions_in_text(SAMPLE_DOC) + names = [d[0] for d in defs] + assert "prism" in names + assert "rhomb" in names + assert "body" in names + + def test_empty_text(self): + """Empty text returns no definitions.""" + defs = _find_definitions_in_text("") + assert defs == [] + + def test_no_definitions(self): + """Text without definitions returns empty.""" + defs = _find_definitions_in_text("cubic[m3m]:{111}") + assert defs == [] + + +class TestReferenceCompletions: + """Test completion items for $ and @ contexts.""" + + def test_dollar_suggests_definitions(self): + """$ suggests all definition names from document.""" + completions = get_completions("$", 1, document_text=SAMPLE_DOC) + labels = [c.label for c in completions] + assert "prism" in labels + assert "rhomb" in labels + assert "body" in labels + + def test_dollar_prefix_filters(self): + """$pr filters to matching definition names.""" + completions = get_completions("$pr", 3, document_text=SAMPLE_DOC) + labels = [c.label for c in completions] + assert "prism" in labels + assert "rhomb" not in labels + + def test_dollar_no_document_returns_empty(self): + """$ without document text returns no completions.""" + completions = get_completions("$", 1, document_text="") + assert len(completions) == 0 + + def test_at_line_start_suggests_template(self): + """@ at line start suggests definition template.""" + completions = get_completions("@", 1) + labels = [c.label for c in completions] + assert any("name" in label for label in labels) + + def test_reference_completion_detail(self): + """Reference completions show expression in documentation.""" + completions = get_completions("$", 1, document_text=SAMPLE_DOC) + prism_item = next(c for c in completions if c.label == "prism") + if hasattr(prism_item, "documentation"): + doc = prism_item.documentation + doc_text = doc.value if hasattr(doc, "value") else str(doc) + assert "{10-10}" in doc_text + + +# ============================================================================ +# Formatting for definition lines +# ============================================================================ + + +class TestDefinitionFormatting: + """Test formatting of @definition lines.""" + + def test_format_definition_normalizes_spacing(self): + """Definition line gets normalized spacing.""" + result = format_line("@prism={10-10}@1.0") + assert result == "@prism = {10-10}@1.0" + + def test_format_definition_preserves_correct_spacing(self): + """Correctly formatted definition is preserved.""" + result = format_line("@prism = {10-10}@1.0") + assert result == "@prism = {10-10}@1.0" + + def test_format_definition_with_extra_spaces(self): + """Definition with extra spaces gets normalized.""" + result = format_line("@prism = {10-10}@1.0") + assert result == "@prism = {10-10}@1.0" + + def test_format_definition_expression_formatted(self): + """Expression part of definition gets formatted too.""" + result = format_line("@body = {111} + {100}") + assert result == "@body = {111} + {100}" + + def test_format_definition_with_leading_whitespace(self): + """Leading whitespace in definition is preserved.""" + result = format_line(" @prism = {10-10}@1.0") + assert result == " @prism = {10-10}@1.0"