ailloli_ui_editor/code/
mod.rs

1//! Code-document metadata plus syntax, search, folding, diagnostics, and symbols.
2//!
3//! # Examples
4//!
5//! ```
6//! use ailloli_ui_editor::{code::detect_language, EditorLanguage};
7//! assert_eq!(detect_language(Some(std::path::Path::new("main.rs"))), EditorLanguage::Rust);
8//! ```
9
10use std::path::{Path, PathBuf};
11
12use ailloli_ui_fs::FileUri;
13use ailloli_ui_text::TextBuffer;
14
15/// Code-editor and gutter configuration.
16pub mod config;
17/// Source diagnostics and byte hit testing.
18pub mod diagnostics;
19/// Logical-line fold regions and discovery.
20pub mod folding;
21/// Cached byte-range text search.
22pub mod search;
23/// Optional LSP and SCIP semantic enrichment models.
24pub mod semantic;
25/// Document-aware editor session state.
26pub mod session;
27/// Local symbol indexing and graph summaries.
28pub mod symbols;
29/// Rust lexical and optional Tree-sitter syntax tokens.
30pub mod syntax;
31
32pub use config::{CodeEditorConfig, CodeEditorFeatureFlags, CodeTheme, GutterConfig};
33pub use diagnostics::{
34    diagnostic_at_byte, Diagnostic, DiagnosticHit, DiagnosticSeverity, DiagnosticSource,
35};
36pub use folding::{
37    collapsed_region_hiding_line, fold_region_at_line, fold_regions_for_document, line_for_byte,
38    line_start_byte, merge_fold_regions_with_previous, FoldRegion, FoldRegionId,
39};
40pub use search::{find_matches, SearchMatch, SearchQuery, SearchState};
41pub use semantic::{
42    collect_lsp_enrichment, import_scip_json_str, lsp_diagnostics_to_diagnostics,
43    lsp_symbols_to_code_symbols, merge_code_file_summaries, scip_document_to_summary,
44    scip_project_to_summary, scip_symbols_to_code_symbols, semantic_references_to_edges,
45    LspBackend, LspCapabilities, LspDiagnostic, LspEnrichment, LspError, LspRequestId,
46    NoopLspBackend, ScipDocumentIndex, ScipImportError, ScipNavigationLink, ScipOccurrence,
47    ScipOccurrenceRole, ScipProjectIndex, ScipProjectMetadata, ScipProjectSummary, ScipRelation,
48    ScipSymbol, SemanticDocumentSymbol, SemanticReference,
49};
50pub use session::CodeEditorSession;
51#[cfg(feature = "tree_sitter")]
52pub use symbols::TreeSitterRustSymbolIndexer;
53pub use symbols::{
54    index_symbols_with_fallback, parse_ctags_json_lines, CodeFileSummary, CodeSymbol, CtagsError,
55    CtagsRunnerConfig, CtagsSymbolIndexer, LexicalRustSymbolIndexer, SymbolEdge, SymbolEdgeKind,
56    SymbolId, SymbolIndexer, SymbolKind, SymbolSource,
57};
58#[cfg(feature = "tree_sitter")]
59pub use syntax::highlight_rust_tree_sitter_hybrid;
60pub use syntax::{highlight_rust_lexical, SyntaxKind, SyntaxToken};
61
62/// Stable identifier for an open document in an editor session.
63///
64/// Zero is valid and no global allocator is implied.
65///
66/// # Examples
67///
68/// ```
69/// use ailloli_ui_editor::DocumentId;
70/// let id = DocumentId(7);
71/// assert_eq!(id.0, 7);
72/// ```
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
74pub struct DocumentId(pub u64);
75
76/// Monotonic revision of a document's buffer content.
77///
78/// The type permits every `i32`; callers define monotonic update policy and
79/// must handle negative or exhausted external revisions explicitly.
80///
81/// # Examples
82///
83/// ```
84/// use ailloli_ui_editor::DocumentVersion;
85/// assert_eq!(DocumentVersion(0).0, 0);
86/// ```
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
88pub struct DocumentVersion(pub i32);
89
90/// Syntax/language selection used by highlighting, folding, and indexing.
91///
92/// # Examples
93///
94/// ```
95/// use ailloli_ui_editor::EditorLanguage;
96/// assert_ne!(EditorLanguage::PlainText, EditorLanguage::Unknown);
97/// ```
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
99pub enum EditorLanguage {
100    /// Explicit unstructured text, also the default for no path.
101    PlainText,
102    /// Rust source.
103    Rust,
104    /// TypeScript or TSX source.
105    TypeScript,
106    /// JavaScript, JSX, MJS, or CJS source.
107    JavaScript,
108    /// JSON data.
109    Json,
110    /// HTML or HTM markup.
111    Html,
112    /// CSS stylesheet.
113    Css,
114    /// Markdown (`.md` or `.markdown`).
115    Markdown,
116    /// A path was present but its extension was not recognized.
117    Unknown,
118}
119
120/// Origin used for path hints and persistence routing.
121///
122/// `Memory` is the default. A non-file URI deliberately has no local path hint.
123///
124/// # Examples
125///
126/// ```
127/// use std::path::{Path, PathBuf};
128/// use ailloli_ui_editor::DocumentSource;
129/// let source = DocumentSource::LocalPath(PathBuf::from("src/lib.rs"));
130/// assert_eq!(source.path_hint(), Some(Path::new("src/lib.rs")));
131/// ```
132#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
133pub enum DocumentSource {
134    /// In-memory content with no external source.
135    #[default]
136    Memory,
137    /// Host-local path.
138    LocalPath(PathBuf),
139    /// Provider-neutral URI, including remote schemes.
140    Uri(FileUri),
141}
142
143/// Path-hint access for document source variants.
144impl DocumentSource {
145    /// Returns a borrowed language/path hint when one exists.
146    ///
147    /// URI paths are interpreted lexically as [`Path`] values even for remote
148    /// schemes; `Memory` returns `None`. No filesystem access occurs.
149    ///
150    /// # Examples
151    ///
152    /// ```
153    /// use std::path::Path;
154    /// use ailloli_ui_editor::DocumentSource;
155    /// assert_eq!(DocumentSource::Memory.path_hint(), None);
156    /// assert_eq!(DocumentSource::LocalPath("main.rs".into()).path_hint(), Some(Path::new("main.rs")));
157    /// ```
158    pub fn path_hint(&self) -> Option<&Path> {
159        match self {
160            Self::Memory => None,
161            Self::LocalPath(path) => Some(path.as_path()),
162            Self::Uri(uri) => Some(Path::new(uri.path())),
163        }
164    }
165}
166
167/// Open document metadata plus rope-backed text.
168///
169/// Fields are public for controlled editor composition. Changing `buffer`
170/// directly does not increment `version` or set `dirty`; session methods should
171/// mediate edits when those invariants matter.
172///
173/// # Examples
174///
175/// ```
176/// use ailloli_ui_editor::{Document, DocumentId, DocumentVersion, EditorLanguage};
177/// use ailloli_ui_text::TextBuffer;
178/// let document = Document::new(DocumentId(1), TextBuffer::from_string("hello"));
179/// assert_eq!(document.version, DocumentVersion(0));
180/// assert_eq!(document.language, EditorLanguage::PlainText);
181/// assert!(!document.dirty);
182/// ```
183#[derive(Debug, Clone)]
184pub struct Document {
185    /// Stable session-local document identity.
186    pub id: DocumentId,
187    /// Optional local path retained for compatibility and language detection.
188    pub path: Option<PathBuf>,
189    /// Canonical memory/path/URI origin description.
190    pub source: DocumentSource,
191    /// Explicit or detected language.
192    pub language: EditorLanguage,
193    /// Content revision; initialized to zero.
194    pub version: DocumentVersion,
195    /// Whether content differs from its externally saved version.
196    pub dirty: bool,
197    /// Rope-backed UTF-8 content.
198    pub buffer: TextBuffer,
199}
200
201/// Document construction and language-hint operations.
202impl Document {
203    /// Creates a clean, version-zero in-memory plain-text document.
204    ///
205    /// # Examples
206    ///
207    /// ```
208    /// use ailloli_ui_editor::{Document, DocumentId, DocumentSource};
209    /// use ailloli_ui_text::TextBuffer;
210    /// let document = Document::new(DocumentId(2), TextBuffer::new());
211    /// assert_eq!(document.source, DocumentSource::Memory);
212    /// assert!(document.path.is_none());
213    /// ```
214    pub fn new(id: DocumentId, buffer: TextBuffer) -> Self {
215        Self {
216            id,
217            path: None,
218            source: DocumentSource::Memory,
219            language: EditorLanguage::PlainText,
220            version: DocumentVersion(0),
221            dirty: false,
222            buffer,
223        }
224    }
225
226    /// Sets both `path` and [`DocumentSource::LocalPath`] without detection.
227    ///
228    /// # Examples
229    ///
230    /// ```
231    /// use ailloli_ui_editor::{Document, DocumentId, DocumentSource};
232    /// use ailloli_ui_text::TextBuffer;
233    /// let document = Document::new(DocumentId(1), TextBuffer::new()).with_path("src/main.rs");
234    /// assert!(matches!(document.source, DocumentSource::LocalPath(_)));
235    /// assert_eq!(document.path.unwrap().to_str(), Some("src/main.rs"));
236    /// ```
237    pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
238        let path = path.into();
239        self.source = DocumentSource::LocalPath(path.clone());
240        self.path = Some(path);
241        self
242    }
243
244    /// Replaces the origin and synchronizes the optional local path.
245    ///
246    /// `Memory` preserves an existing legacy `path`, a local file URI converts
247    /// to a host path when possible, and non-file URIs clear `path`.
248    ///
249    /// # Examples
250    ///
251    /// ```
252    /// use ailloli_ui_editor::{Document, DocumentId, DocumentSource};
253    /// use ailloli_ui_fs::FileUri;
254    /// use ailloli_ui_text::TextBuffer;
255    /// let document = Document::new(DocumentId(1), TextBuffer::new())
256    ///     .with_source(DocumentSource::Uri(FileUri::parse("sftp://host/main.rs")?));
257    /// assert!(document.path.is_none());
258    /// # Ok::<(), Box<dyn std::error::Error>>(())
259    /// ```
260    pub fn with_source(mut self, source: DocumentSource) -> Self {
261        self.path = match &source {
262            DocumentSource::Memory => self.path.take(),
263            DocumentSource::LocalPath(path) => Some(path.clone()),
264            DocumentSource::Uri(uri) if uri.scheme() == "file" => uri.to_local_path().ok(),
265            DocumentSource::Uri(_) => None,
266        };
267        self.source = source;
268        self
269    }
270
271    /// Convenience wrapper for [`Self::with_source`] with a URI origin.
272    ///
273    /// # Examples
274    ///
275    /// ```
276    /// use ailloli_ui_editor::{Document, DocumentId, DocumentSource};
277    /// use ailloli_ui_fs::FileUri;
278    /// use ailloli_ui_text::TextBuffer;
279    /// let document = Document::new(DocumentId(1), TextBuffer::new()).with_uri(FileUri::parse("sftp://host/doc")?);
280    /// assert!(matches!(document.source, DocumentSource::Uri(_)));
281    /// # Ok::<(), Box<dyn std::error::Error>>(())
282    /// ```
283    pub fn with_uri(self, uri: FileUri) -> Self {
284        self.with_source(DocumentSource::Uri(uri))
285    }
286
287    /// Replaces the language without inspecting the path or buffer.
288    ///
289    /// # Examples
290    ///
291    /// ```
292    /// use ailloli_ui_editor::{Document, DocumentId, EditorLanguage};
293    /// use ailloli_ui_text::TextBuffer;
294    /// let document = Document::new(DocumentId(1), TextBuffer::new()).with_language(EditorLanguage::Rust);
295    /// assert_eq!(document.language, EditorLanguage::Rust);
296    /// ```
297    pub fn with_language(mut self, language: EditorLanguage) -> Self {
298        self.language = language;
299        self
300    }
301
302    /// Replaces the current language with extension-based detection.
303    ///
304    /// With no hint this selects `PlainText`; an unrecognized extension selects
305    /// `Unknown`, even if a different explicit language was previously set.
306    ///
307    /// # Examples
308    ///
309    /// ```
310    /// use ailloli_ui_editor::{Document, DocumentId, EditorLanguage};
311    /// use ailloli_ui_text::TextBuffer;
312    /// let mut document = Document::new(DocumentId(1), TextBuffer::new()).with_path("lib.rs");
313    /// document.detect_language();
314    /// assert_eq!(document.language, EditorLanguage::Rust);
315    /// ```
316    pub fn detect_language(&mut self) {
317        self.language = detect_language(self.language_path_hint());
318    }
319
320    /// Returns `path` first, then the source-derived hint.
321    ///
322    /// # Examples
323    ///
324    /// ```
325    /// use std::path::Path;
326    /// use ailloli_ui_editor::{Document, DocumentId};
327    /// use ailloli_ui_text::TextBuffer;
328    /// let document = Document::new(DocumentId(1), TextBuffer::new()).with_path("main.rs");
329    /// assert_eq!(document.language_path_hint(), Some(Path::new("main.rs")));
330    /// ```
331    pub fn language_path_hint(&self) -> Option<&Path> {
332        self.path.as_deref().or_else(|| self.source.path_hint())
333    }
334}
335
336/// Resolves an override, explicit language, or extension-derived fallback.
337///
338/// `override_language` always wins, including `Unknown`. Without it, explicit
339/// languages other than `PlainText`/`Unknown` win. Failed detection preserves
340/// the document's existing plain/unknown value.
341///
342/// # Examples
343///
344/// ```
345/// use ailloli_ui_editor::{resolve_document_language, Document, DocumentId, EditorLanguage};
346/// use ailloli_ui_text::TextBuffer;
347/// let document = Document::new(DocumentId(1), TextBuffer::new()).with_path("lib.rs");
348/// assert_eq!(resolve_document_language(&document, None), EditorLanguage::Rust);
349/// assert_eq!(resolve_document_language(&document, Some(EditorLanguage::Markdown)), EditorLanguage::Markdown);
350/// ```
351pub fn resolve_document_language(
352    document: &Document,
353    override_language: Option<EditorLanguage>,
354) -> EditorLanguage {
355    if let Some(language) = override_language {
356        return language;
357    }
358    match document.language {
359        EditorLanguage::PlainText | EditorLanguage::Unknown => {
360            let detected = detect_language(document.language_path_hint());
361            if detected == EditorLanguage::Unknown {
362                document.language
363            } else {
364                detected
365            }
366        }
367        language => language,
368    }
369}
370
371/// Detects language solely from a case-insensitive filename extension.
372///
373/// No path returns `PlainText`; an absent, non-UTF-8, or unsupported extension
374/// returns `Unknown`. The function performs no I/O and never examines content.
375///
376/// # Examples
377///
378/// ```
379/// use std::path::Path;
380/// use ailloli_ui_editor::{code::detect_language, EditorLanguage};
381/// assert_eq!(detect_language(Some(Path::new("view.TSX"))), EditorLanguage::TypeScript);
382/// assert_eq!(detect_language(Some(Path::new("README"))), EditorLanguage::Unknown);
383/// assert_eq!(detect_language(None), EditorLanguage::PlainText);
384/// ```
385pub fn detect_language(path: Option<&std::path::Path>) -> EditorLanguage {
386    let Some(path) = path else {
387        return EditorLanguage::PlainText;
388    };
389    match path
390        .extension()
391        .and_then(|extension| extension.to_str())
392        .unwrap_or_default()
393        .to_ascii_lowercase()
394        .as_str()
395    {
396        "rs" => EditorLanguage::Rust,
397        "ts" | "tsx" => EditorLanguage::TypeScript,
398        "js" | "jsx" | "mjs" | "cjs" => EditorLanguage::JavaScript,
399        "json" => EditorLanguage::Json,
400        "html" | "htm" => EditorLanguage::Html,
401        "css" => EditorLanguage::Css,
402        "md" | "markdown" => EditorLanguage::Markdown,
403        _ => EditorLanguage::Unknown,
404    }
405}