nautilus-orm-lsp 0.1.4

LSP server for .nautilus schema files
//! Conversion helpers between nautilus-schema byte-offset types and LSP line/character types.
//!
//! LSP positions are 0-indexed (line, character) where `character` is a UTF-16
//! code-unit count.  Because `.nautilus` schema files are virtually always ASCII,
//! this implementation treats each byte as one UTF-16 code unit.  Non-ASCII
//! content in string literals or comments will produce slightly incorrect hover
//! ranges, but correctness of diagnostics (which matter most) is unaffected.

use nautilus_schema::{
    analysis::{CompletionItem, CompletionKind, HoverInfo, SemanticKind, SemanticToken},
    diagnostic::{Diagnostic, Severity},
    Span,
};
use tower_lsp::lsp_types::{
    self, CompletionItemKind, DiagnosticSeverity, InsertTextFormat, Position, Range,
    SemanticToken as LspSemanticToken,
};

/// Convert a byte `offset` in `source` to an LSP [`Position`].
///
/// The returned position is 0-indexed (line, character).
pub fn offset_to_position(source: &str, offset: usize) -> Position {
    let safe = offset.min(source.len());
    let prefix = &source[..safe];
    let line = prefix.bytes().filter(|&b| b == b'\n').count() as u32;
    let last_nl = prefix.rfind('\n').map(|i| i + 1).unwrap_or(0);
    // Use char count so non-ASCII is handled better than raw bytes.
    let character = prefix[last_nl..].chars().count() as u32;
    Position { line, character }
}

/// Convert an LSP [`Position`] to a byte offset in `source`.
///
/// Clamps to `source.len()` if the position is past the end.
pub fn position_to_offset(source: &str, pos: Position) -> usize {
    let mut current_line = 0u32;
    let mut line_start = 0usize;

    for (i, ch) in source.char_indices() {
        if current_line == pos.line {
            break;
        }
        if ch == '\n' {
            current_line += 1;
            line_start = i + ch.len_utf8();
        }
    }

    // Now advance `pos.character` chars from `line_start`.
    let rest = &source[line_start..];
    let byte_offset = rest
        .char_indices()
        .nth(pos.character as usize)
        .map(|(i, _)| i)
        .unwrap_or(rest.len());

    (line_start + byte_offset).min(source.len())
}

pub fn span_to_range(source: &str, span: &Span) -> Range {
    Range {
        start: offset_to_position(source, span.start),
        end: offset_to_position(source, span.end),
    }
}

pub fn nautilus_diagnostic_to_lsp(source: &str, d: &Diagnostic) -> lsp_types::Diagnostic {
    let severity = match d.severity {
        Severity::Error => DiagnosticSeverity::ERROR,
        Severity::Warning => DiagnosticSeverity::WARNING,
    };
    lsp_types::Diagnostic {
        range: span_to_range(source, &d.span),
        severity: Some(severity),
        message: d.message.clone(),
        source: Some("nautilus-schema".to_string()),
        ..Default::default()
    }
}

pub fn nautilus_completion_to_lsp(item: &CompletionItem) -> lsp_types::CompletionItem {
    let kind = match item.kind {
        CompletionKind::Keyword => CompletionItemKind::KEYWORD,
        CompletionKind::Type => CompletionItemKind::CLASS,
        CompletionKind::FieldAttribute => CompletionItemKind::PROPERTY,
        CompletionKind::ModelAttribute => CompletionItemKind::PROPERTY,
        CompletionKind::ModelName => CompletionItemKind::STRUCT,
        CompletionKind::EnumName => CompletionItemKind::ENUM,
        CompletionKind::FieldName => CompletionItemKind::FIELD,
    };
    lsp_types::CompletionItem {
        label: item.label.clone(),
        kind: Some(kind),
        detail: item.detail.clone(),
        insert_text: item.insert_text.clone(),
        insert_text_format: if item.is_snippet {
            Some(InsertTextFormat::SNIPPET)
        } else {
            None
        },
        ..Default::default()
    }
}

pub fn hover_info_to_lsp(source: &str, h: &HoverInfo) -> lsp_types::Hover {
    let range = h.span.as_ref().map(|s| span_to_range(source, s));
    lsp_types::Hover {
        contents: lsp_types::HoverContents::Markup(lsp_types::MarkupContent {
            kind: lsp_types::MarkupKind::Markdown,
            value: h.content.clone(),
        }),
        range,
    }
}

/// Encode a sorted list of [`SemanticToken`]s into the LSP delta format.
///
/// Token types legend (must match `SemanticTokensLegend` in `initialize`):
/// - `0` → `nautilusModel`        (model reference)
/// - `1` → `nautilusEnum`         (enum reference)
/// - `2` → `nautilusCompositeType` (composite type reference)
pub fn semantic_tokens_to_lsp(source: &str, tokens: &[SemanticToken]) -> Vec<LspSemanticToken> {
    let mut result = Vec::with_capacity(tokens.len());
    let mut prev_line = 0u32;
    let mut prev_start = 0u32;

    for token in tokens {
        let pos = offset_to_position(source, token.span.start);
        let length = (token.span.end - token.span.start) as u32;

        let delta_line = pos.line - prev_line;
        let delta_start = if delta_line == 0 {
            pos.character - prev_start
        } else {
            pos.character
        };

        let token_type = match token.kind {
            SemanticKind::ModelRef => 0,
            SemanticKind::EnumRef => 1,
            SemanticKind::CompositeTypeRef => 2,
        };

        result.push(LspSemanticToken {
            delta_line,
            delta_start,
            length,
            token_type,
            token_modifiers_bitset: 0,
        });

        prev_line = pos.line;
        prev_start = pos.character;
    }

    result
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn round_trip_offset_position() {
        let source = "model User {\n  id Int\n  name String\n}";
        // offset 13 is the start of line 1, col 0
        let pos = offset_to_position(source, 13);
        assert_eq!(
            pos,
            Position {
                line: 1,
                character: 0
            }
        );
        let back = position_to_offset(source, pos);
        assert_eq!(back, 13);
    }

    #[test]
    fn offset_to_position_at_col() {
        let source = "model User {\n  id Int\n}";
        // offset 5 = "model" → 'U" — still on line 0
        let pos = offset_to_position(source, 5);
        assert_eq!(
            pos,
            Position {
                line: 0,
                character: 5
            }
        );
    }
}