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}