Skip to main content

reflex/
models.rs

1//! Core data models for Reflex
2//!
3//! These structures represent the normalized, deterministic output format
4//! that Reflex provides to AI agents and other programmatic consumers.
5
6use serde::{Deserialize, Serialize};
7use strum::{Display, EnumString};
8
9/// Represents a source code location span (line range only)
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct Span {
12    /// Starting line number (1-indexed)
13    pub start_line: usize,
14    /// Ending line number (1-indexed)
15    pub end_line: usize,
16}
17
18impl Span {
19    pub fn new(start_line: usize, start_col: usize, end_line: usize, end_col: usize) -> Self {
20        // Ignore col parameters for backwards compatibility
21        let _ = (start_col, end_col);
22        Self {
23            start_line,
24            end_line,
25        }
26    }
27}
28
29/// Type of symbol found in code
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, EnumString, Display)]
31#[strum(serialize_all = "PascalCase")]
32pub enum SymbolKind {
33    Function,
34    Class,
35    Struct,
36    Enum,
37    Interface,
38    Trait,
39    Constant,
40    Variable,
41    Method,
42    Module,
43    Namespace,
44    Type,
45    Macro,
46    Property,
47    Event,
48    Import,
49    Export,
50    Attribute,
51    /// Catch-all for symbol kinds not yet explicitly supported.
52    /// This ensures no data loss when encountering new tree-sitter node types.
53    /// The string contains the original kind name from the parser.
54    #[strum(default)]
55    Unknown(String),
56}
57
58/// Programming language identifier
59#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
60#[serde(rename_all = "lowercase")]
61pub enum Language {
62    #[default]
63    Rust,
64    Python,
65    JavaScript,
66    TypeScript,
67    Vue,
68    Svelte,
69    Go,
70    Java,
71    PHP,
72    C,
73    Cpp,
74    CSharp,
75    Ruby,
76    Kotlin,
77    Swift,
78    Zig,
79    /// Plain-text tier: documentation, config and templates.
80    ///
81    /// Trigram-indexed only — no tree-sitter grammar, no symbol extraction, no
82    /// import extraction. Added because agents do not partition searches by file
83    /// type: a config key lives in the YAML, the Rust struct AND the spec paragraph,
84    /// and Reflex used to return the struct and a confident 0 for the rest.
85    ///
86    /// Serialises as `"text"` (the enum is `rename_all = "lowercase"`).
87    Text,
88    Unknown,
89}
90
91/// Extensions in the plain-text tier.
92///
93/// A fixed allowlist, not "everything unrecognised": an index that swallowed every
94/// binary blob and generated artefact in a repo would be slower and less useful.
95const TEXT_EXTENSIONS: &[&str] = &[
96    "md", "mdx", "txt", "yaml", "yml", "toml", "json", "proto", "html", "htm", "sh", "bash", "ini",
97    "cfg", "sql", "graphql",
98];
99
100/// Filenames excluded from the text tier despite a matching extension.
101///
102/// Lock files are the reason the tier needs an exclusion list at all: a
103/// `package-lock.json` is 100k+ lines of near-random trigrams, which bloats posting
104/// lists without ever being something a person searches for.
105const TEXT_FILENAME_EXCLUSIONS: &[&str] = &[
106    "package-lock.json",
107    "composer.lock",
108    "yarn.lock",
109    "pnpm-lock.yaml",
110    "Cargo.lock",
111    "poetry.lock",
112    "Gemfile.lock",
113];
114
115/// Whether a file belongs in the text tier, judged by its full name.
116///
117/// Takes the file NAME, not just the extension, so lock files can be excluded.
118pub fn is_text_tier_file(file_name: &str) -> bool {
119    if TEXT_FILENAME_EXCLUSIONS
120        .iter()
121        .any(|n| n.eq_ignore_ascii_case(file_name))
122    {
123        return false;
124    }
125    // `*-lock.json` and friends, beyond the names listed above.
126    if file_name.ends_with("-lock.json") || file_name.ends_with(".lock") {
127        return false;
128    }
129    match file_name.rsplit_once('.') {
130        Some((_, ext)) => TEXT_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()),
131        None => false,
132    }
133}
134
135impl Language {
136    pub fn from_extension(ext: &str) -> Self {
137        match ext {
138            "rs" => Language::Rust,
139            "py" => Language::Python,
140            "js" | "mjs" | "cjs" | "jsx" => Language::JavaScript,
141            "ts" | "mts" | "cts" | "tsx" => Language::TypeScript,
142            "vue" => Language::Vue,
143            "svelte" => Language::Svelte,
144            "go" => Language::Go,
145            "java" => Language::Java,
146            "php" => Language::PHP,
147            "c" | "h" => Language::C,
148            "cpp" | "cc" | "cxx" | "hpp" | "hxx" | "C" | "H" => Language::Cpp,
149            "cs" => Language::CSharp,
150            "rb" | "rake" | "gemspec" => Language::Ruby,
151            "kt" | "kts" => Language::Kotlin,
152            "swift" => Language::Swift,
153            "zig" => Language::Zig,
154            // The text tier. Note this maps by EXTENSION only; `is_text_tier_file`
155            // additionally excludes lock files by name, and the indexer uses that.
156            ext if TEXT_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()) => Language::Text,
157            _ => Language::Unknown,
158        }
159    }
160
161    /// Parse a language from a human-friendly name (CLI/API input)
162    ///
163    /// Accepts lowercase names and common aliases.
164    /// Returns None for unrecognized names.
165    pub fn from_name(name: &str) -> Option<Self> {
166        match name.to_lowercase().as_str() {
167            "rust" | "rs" => Some(Language::Rust),
168            "python" | "py" => Some(Language::Python),
169            "javascript" | "js" => Some(Language::JavaScript),
170            "typescript" | "ts" => Some(Language::TypeScript),
171            "vue" => Some(Language::Vue),
172            "svelte" => Some(Language::Svelte),
173            "go" => Some(Language::Go),
174            "java" => Some(Language::Java),
175            "php" => Some(Language::PHP),
176            "c" => Some(Language::C),
177            "cpp" | "c++" => Some(Language::Cpp),
178            "csharp" | "cs" | "c#" => Some(Language::CSharp),
179            "ruby" | "rb" => Some(Language::Ruby),
180            "kotlin" | "kt" => Some(Language::Kotlin),
181            "zig" => Some(Language::Zig),
182            "text" | "txt" | "plaintext" | "plain" => Some(Language::Text),
183            _ => None,
184        }
185    }
186
187    /// Human-readable list of all supported language names (for error messages)
188    pub fn supported_names_help() -> &'static str {
189        "rust (rs), python (py), javascript (js), typescript (ts), vue, svelte, \
190         go, java, php, c, cpp (c++), csharp (cs, c#), ruby (rb), kotlin (kt), zig, \
191         text (docs and config: md, yaml, toml, json, proto, html, sh, sql, graphql)"
192    }
193
194    /// Check if this language has a parser implementation
195    ///
196    /// Returns true only for languages with working Tree-sitter parsers.
197    /// This determines which files will be indexed by Reflex.
198    pub fn is_supported(&self) -> bool {
199        match self {
200            Language::Rust => true,
201            Language::TypeScript => true,
202            Language::JavaScript => true,
203            Language::Vue => true,
204            Language::Svelte => true,
205            Language::Python => true,
206            Language::Go => true,
207            Language::Java => true,
208            Language::PHP => true,
209            Language::C => true,
210            Language::Cpp => true,
211            Language::CSharp => true,
212            Language::Ruby => true,
213            Language::Kotlin => true,
214            Language::Swift => false, // Temporarily disabled - parser queries out of date with tree-sitter-swift 0.7.x grammar
215            Language::Zig => true,
216            // No tree-sitter grammar, by design.
217            Language::Text => false,
218            Language::Unknown => false,
219        }
220    }
221
222    /// Whether this is the plain-text tier.
223    pub fn is_text(&self) -> bool {
224        matches!(self, Language::Text)
225    }
226
227    /// Whether files of this language are indexed at all.
228    ///
229    /// Distinct from [`Self::is_supported`], which means "has a tree-sitter parser".
230    /// The text tier is indexed but never parsed, so symbol search, AST queries and
231    /// dependency analysis skip it while full-text search covers it.
232    pub fn is_indexable(&self) -> bool {
233        self.is_supported() || self.is_text()
234    }
235}
236
237/// Type of import/dependency
238#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
239#[serde(rename_all = "lowercase")]
240pub enum ImportType {
241    /// Internal project file
242    Internal,
243    /// External library/package
244    External,
245    /// Standard library
246    Stdlib,
247    /// Rust `mod foo;` declaration (parent→child ownership, not a usage edge)
248    #[serde(rename = "mod_decl")]
249    ModDecl,
250}
251
252/// Dependency information for API output (simplified, path-based)
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct DependencyInfo {
255    /// Import path as written in source (or resolved path for internal deps)
256    pub path: String,
257    /// Line number where import appears (optional)
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub line: Option<usize>,
260    /// Imported symbols (for selective imports like `from x import a, b`)
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub symbols: Option<Vec<String>>,
263}
264
265/// Full dependency record (internal representation with file IDs)
266#[derive(Debug, Clone)]
267pub struct Dependency {
268    /// Source file ID
269    pub file_id: i64,
270    /// Import path as written in source code
271    pub imported_path: String,
272    /// Resolved file ID (None if external or stdlib)
273    pub resolved_file_id: Option<i64>,
274    /// Import type classification
275    pub import_type: ImportType,
276    /// Line number where import appears
277    pub line_number: usize,
278    /// Imported symbols (for selective imports)
279    pub imported_symbols: Option<Vec<String>>,
280}
281
282/// A lightweight, stable reference to a code symbol for API responses
283///
284/// Prefer this over `(String, SymbolKind, Span)` tuples — tuples serialize as
285/// positional JSON arrays, making any field addition a breaking change.
286/// Named fields here are additive-safe: new optional fields can be added without
287/// shifting positions or bumping the version.
288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
289pub struct SymbolRef {
290    /// Symbol name (e.g., function name, class name)
291    pub name: String,
292    /// Symbol kind (function, class, struct, etc.)
293    pub kind: SymbolKind,
294    /// Location span in source file
295    pub span: Span,
296}
297
298/// Helper function to skip serializing "Unknown" symbol kinds
299fn is_unknown_kind(kind: &SymbolKind) -> bool {
300    matches!(kind, SymbolKind::Unknown(_))
301}
302
303/// A search result representing a symbol or code location
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct SearchResult {
306    /// Absolute or relative path to the file
307    pub path: String,
308    /// Detected programming language (internal use only, not serialized to save tokens)
309    #[serde(skip)]
310    pub lang: Language,
311    /// Type of symbol found (only included for symbol searches, not text matches)
312    #[serde(skip_serializing_if = "is_unknown_kind")]
313    pub kind: SymbolKind,
314    /// Symbol name (e.g., function name, class name)
315    /// None for text/regex matches where symbol name cannot be accurately determined
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub symbol: Option<String>,
318    /// Location span in the source file
319    pub span: Span,
320    /// Code preview (few lines around the match)
321    pub preview: String,
322    /// File dependencies (only populated when --dependencies flag is used)
323    /// DEPRECATED: Use FileGroupedResult.dependencies instead for file-level grouping
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub dependencies: Option<Vec<DependencyInfo>>,
326}
327
328/// An individual match within a file (no path or dependencies)
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct MatchResult {
331    /// Type of symbol found (only included for symbol searches, not text matches)
332    #[serde(skip_serializing_if = "is_unknown_kind")]
333    pub kind: SymbolKind,
334    /// Symbol name (e.g., function name, class name)
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub symbol: Option<String>,
337    /// Location span in the source file
338    pub span: Span,
339    /// Code preview (few lines around the match)
340    pub preview: String,
341    /// Lines of code before the match (for context)
342    #[serde(skip_serializing_if = "Vec::is_empty")]
343    pub context_before: Vec<String>,
344    /// Lines of code after the match (for context)
345    #[serde(skip_serializing_if = "Vec::is_empty")]
346    pub context_after: Vec<String>,
347}
348
349/// File-level grouped results with dependencies at file level
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct FileGroupedResult {
352    /// Absolute or relative path to the file
353    pub path: String,
354    /// Detected programming language of this file (e.g. "rust", "python", "unknown")
355    pub language: Language,
356    /// File dependencies (only populated when --dependencies flag is used)
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub dependencies: Option<Vec<DependencyInfo>>,
359    /// Individual matches within this file
360    pub matches: Vec<MatchResult>,
361}
362
363impl SearchResult {
364    pub fn new(
365        path: String,
366        lang: Language,
367        kind: SymbolKind,
368        symbol: Option<String>,
369        span: Span,
370        scope: Option<String>,
371        preview: String,
372    ) -> Self {
373        // Ignore scope parameter for backwards compatibility
374        let _ = scope;
375        Self {
376            path,
377            lang,
378            kind,
379            symbol,
380            span,
381            preview,
382            dependencies: None,
383        }
384    }
385}
386
387/// Configuration for indexing behavior
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct IndexConfig {
390    /// Languages to include (empty = all supported)
391    pub languages: Vec<Language>,
392    /// Glob patterns to include
393    pub include_patterns: Vec<String>,
394    /// Glob patterns to exclude
395    pub exclude_patterns: Vec<String>,
396    /// Follow symbolic links
397    pub follow_symlinks: bool,
398    /// Maximum file size to index (bytes)
399    pub max_file_size: usize,
400    /// Number of threads for parallel indexing (0 = auto, 80% of available cores)
401    pub parallel_threads: usize,
402    /// Query timeout in seconds (0 = no timeout)
403    pub query_timeout_secs: u64,
404    /// Maximum entries per trigram posting list (0 = unlimited).
405    /// High-frequency trigrams are truncated at this threshold to bound query latency.
406    pub max_posting_list_entries: usize,
407    /// How long `Indexer::index` waits for `.reflex/index.lock` when another
408    /// indexer holds it (seconds). 0 = fail immediately with `IndexLocked`.
409    /// The `rfx index` CLI waits; MCP, watcher and HTTP callers fail fast.
410    #[serde(default, skip_serializing_if = "is_zero_u64")]
411    pub lock_wait_secs: u64,
412    /// Index documentation, config and template files alongside code.
413    ///
414    /// On by default. Covers md, mdx, txt, yaml, yml, toml, json, proto, html, htm,
415    /// sh, bash, ini, cfg, sql and graphql, trigram-indexed only — no symbols, no
416    /// AST, no dependency analysis. Lock files are always excluded.
417    ///
418    /// Set `[index] text_tier = false` for a repo with large generated JSON or
419    /// vendored documentation where the index growth is not worth it.
420    ///
421    /// `#[serde(default = ...)]` so a config file written before 1.7.2 still parses
422    /// and gets the new default.
423    #[serde(default = "default_true")]
424    pub text_tier: bool,
425}
426
427/// Serde default for boolean options that are on unless explicitly disabled.
428fn default_true() -> bool {
429    true
430}
431
432impl Default for IndexConfig {
433    fn default() -> Self {
434        Self {
435            languages: vec![],
436            include_patterns: vec![],
437            exclude_patterns: vec![],
438            follow_symlinks: false,
439            max_file_size: 10 * 1024 * 1024,   // 10 MB
440            parallel_threads: 0,               // 0 = auto (80% of available cores)
441            query_timeout_secs: 30,            // 30 seconds default timeout
442            max_posting_list_entries: 500_000, // cap at 500k to bound query latency
443            text_tier: true,                   // docs and config are searchable by default
444            lock_wait_secs: 0,                 // fail fast when another indexer runs
445        }
446    }
447}
448
449fn is_zero(v: &usize) -> bool {
450    *v == 0
451}
452fn is_zero_u64(v: &u64) -> bool {
453    *v == 0
454}
455
456/// Statistics about the index
457#[derive(Debug, Clone, Serialize, Deserialize, Default)]
458pub struct IndexStats {
459    /// Total files indexed
460    pub total_files: usize,
461    /// Index size on disk (bytes)
462    pub index_size_bytes: u64,
463    /// Last update timestamp
464    pub last_updated: String,
465    /// File count breakdown by language
466    pub files_by_language: std::collections::HashMap<String, usize>,
467    /// Line count breakdown by language
468    pub lines_by_language: std::collections::HashMap<String, usize>,
469    /// New files added since last index run (0 if not an incremental run)
470    #[serde(default, skip_serializing_if = "is_zero")]
471    pub new_files: usize,
472    /// Modified files re-indexed since last run (0 if not an incremental run)
473    #[serde(default, skip_serializing_if = "is_zero")]
474    pub modified_files: usize,
475    /// Unchanged files (same hash as last run, still re-indexed due to other changes)
476    #[serde(default, skip_serializing_if = "is_zero")]
477    pub unchanged_files: usize,
478    /// Files skipped because they exceeded max_file_size
479    #[serde(default, skip_serializing_if = "is_zero")]
480    pub skipped_too_large: usize,
481    /// Total bytes of files skipped due to max_file_size
482    #[serde(default, skip_serializing_if = "is_zero_u64")]
483    pub skipped_bytes_too_large: u64,
484}
485
486/// Information about an indexed file
487#[derive(Debug, Clone, Serialize, Deserialize)]
488pub struct IndexedFile {
489    /// File path
490    pub path: String,
491    /// Detected language
492    pub language: String,
493    /// Last indexed timestamp
494    pub last_indexed: String,
495}
496
497/// Index status for query responses
498#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
499#[serde(rename_all = "snake_case")]
500pub enum IndexStatus {
501    /// Index is fresh and up-to-date
502    Fresh,
503    /// Index is stale (any issue: branch not indexed, commit changed, files modified)
504    Stale,
505}
506
507/// Warning details when index is stale
508#[derive(Debug, Clone, Serialize, Deserialize)]
509pub struct IndexWarning {
510    /// Human-readable reason why index is stale
511    pub reason: String,
512    /// Command to run to fix the issue
513    pub action_required: String,
514    /// Tracked files edited since the index was built.
515    ///
516    /// BREAKING in 1.7.2: this was a `u32` count. It is now the paths themselves,
517    /// because a count told an agent something was wrong without telling it what, so
518    /// the only safe reaction was to distrust the whole result. The list is capped;
519    /// `truncated` says when.
520    #[serde(skip_serializing_if = "Option::is_none")]
521    pub files_modified: Option<Vec<String>>,
522    /// Files present on disk but absent from the index (new or untracked).
523    #[serde(skip_serializing_if = "Option::is_none")]
524    pub files_added: Option<Vec<String>>,
525    /// Files in the index but no longer on disk. These produce ghost hits.
526    #[serde(skip_serializing_if = "Option::is_none")]
527    pub files_deleted: Option<Vec<String>>,
528    /// Total changed paths, which may exceed the lengths of the lists above.
529    #[serde(skip_serializing_if = "Option::is_none")]
530    pub changed_count: Option<usize>,
531    /// Whether the lists were cut short. Set on a fresh checkout or a huge rebase.
532    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
533    pub truncated: bool,
534    /// Additional context (git branch info, etc.)
535    #[serde(skip_serializing_if = "Option::is_none")]
536    pub details: Option<IndexWarningDetails>,
537}
538
539impl IndexWarning {
540    /// A warning with only a reason and an action, no file lists.
541    pub fn new(reason: impl Into<String>, action_required: impl Into<String>) -> Self {
542        Self {
543            reason: reason.into(),
544            action_required: action_required.into(),
545            files_modified: None,
546            files_added: None,
547            files_deleted: None,
548            changed_count: None,
549            truncated: false,
550            details: None,
551        }
552    }
553
554    /// Attach git branch/commit context.
555    pub fn with_details(mut self, details: IndexWarningDetails) -> Self {
556        self.details = Some(details);
557        self
558    }
559}
560
561/// Detailed information about index staleness
562#[derive(Debug, Clone, Serialize, Deserialize)]
563pub struct IndexWarningDetails {
564    /// Current branch (if in git repo)
565    #[serde(skip_serializing_if = "Option::is_none")]
566    pub current_branch: Option<String>,
567    /// Indexed branch (if in git repo)
568    #[serde(skip_serializing_if = "Option::is_none")]
569    pub indexed_branch: Option<String>,
570    /// Current commit SHA (if in git repo)
571    #[serde(skip_serializing_if = "Option::is_none")]
572    pub current_commit: Option<String>,
573    /// Indexed commit SHA (if in git repo)
574    #[serde(skip_serializing_if = "Option::is_none")]
575    pub indexed_commit: Option<String>,
576}
577
578/// Pagination information for query results
579#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct PaginationInfo {
581    /// Total number of results (before offset/limit applied)
582    pub total: usize,
583    /// Number of results in this response (after offset/limit)
584    pub count: usize,
585    /// Offset used (starting position)
586    pub offset: usize,
587    /// Limit used (max results per page)
588    #[serde(skip_serializing_if = "Option::is_none")]
589    pub limit: Option<usize>,
590    /// Whether there are more results after this page
591    pub has_more: bool,
592}
593
594/// Query response with results and index status
595#[derive(Debug, Clone, Serialize, Deserialize)]
596pub struct QueryResponse {
597    /// AI-optimized instruction for how to handle these results
598    /// Only present when --ai flag is used or in MCP mode
599    /// Provides guidance to AI agents on response format and next actions
600    #[serde(skip_serializing_if = "Option::is_none")]
601    pub ai_instruction: Option<String>,
602    /// Status of the index (fresh or stale)
603    pub status: IndexStatus,
604    /// Whether the results can be trusted
605    pub can_trust_results: bool,
606    /// Warning information (only present if stale)
607    #[serde(skip_serializing_if = "Option::is_none")]
608    pub warning: Option<IndexWarning>,
609    /// Pagination information
610    pub pagination: PaginationInfo,
611    /// File-grouped search results
612    /// Results are always grouped by file path, with dependencies populated when --dependencies flag is used
613    pub results: Vec<FileGroupedResult>,
614}
615
616/// Report from cache compaction operation
617#[derive(Debug, Clone, Serialize, Deserialize)]
618pub struct CompactionReport {
619    /// Number of files removed
620    pub files_removed: usize,
621    /// Space saved in bytes
622    pub space_saved_bytes: u64,
623    /// Duration in milliseconds
624    pub duration_ms: u64,
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    #[test]
632    fn test_symbol_ref_json_shape() {
633        let sym = SymbolRef {
634            name: "my_function".to_string(),
635            kind: SymbolKind::Function,
636            span: Span {
637                start_line: 10,
638                end_line: 20,
639            },
640        };
641        let json = serde_json::to_value(&sym).unwrap();
642        assert_eq!(json["name"], "my_function");
643        assert_eq!(json["kind"], "Function");
644        assert_eq!(json["span"]["start_line"], 10);
645        assert_eq!(json["span"]["end_line"], 20);
646        assert!(json.as_array().is_none());
647    }
648
649    #[test]
650    fn test_symbol_ref_roundtrip() {
651        let original = SymbolRef {
652            name: "MyStruct".to_string(),
653            kind: SymbolKind::Struct,
654            span: Span {
655                start_line: 1,
656                end_line: 5,
657            },
658        };
659        let json = serde_json::to_string(&original).unwrap();
660        let decoded: SymbolRef = serde_json::from_str(&json).unwrap();
661        assert_eq!(original, decoded);
662    }
663
664    #[test]
665    fn test_symbol_ref_exact_json() {
666        let sym = SymbolRef {
667            name: "Foo".to_string(),
668            kind: SymbolKind::Class,
669            span: Span {
670                start_line: 3,
671                end_line: 7,
672            },
673        };
674        let json = serde_json::to_string(&sym).unwrap();
675        assert_eq!(
676            json,
677            r#"{"name":"Foo","kind":"Class","span":{"start_line":3,"end_line":7}}"#
678        );
679    }
680}