[ty] Avoid retaining transient source text
diff --git a/crates/ruff_db/src/diagnostic/render/full.rs b/crates/ruff_db/src/diagnostic/render/full.rs
index 6a006a0..5dc178d 100644
--- a/crates/ruff_db/src/diagnostic/render/full.rs
+++ b/crates/ruff_db/src/diagnostic/render/full.rs
@@ -553,14 +553,7 @@
             .primary("example.py", "1:24", "1:24", "")
             .build();
 
-        insta::assert_snapshot!(env.render(&diagnostic), @r#"
-        error[invalid-character-sub]: Invalid unescaped character SUB, use "\x1a" instead
-         --> example.py:1:25
-          |
-        1 | nested_fstrings = f'␈{f'{f'␛'}'}'
-          |                         ^
-          |
-        "#);
+        insta::assert_snapshot!(env.render(&diagnostic), @"error[invalid-character-sub]: Invalid unescaped character SUB, use \"\\x1a\" instead\n --> example.py:1:25\n  |\n1 | nested_fstrings = f'␈{f'\u{1a}{f'␛'}'}'\n  |                         ^\n  |");
     }
 
     #[test]
@@ -578,14 +571,7 @@
             .primary("example.py", "1:1", "1:1", "")
             .build();
 
-        insta::assert_snapshot!(env.render(&diagnostic), @r#"
-        error[invalid-character-sub]: Invalid unescaped character SUB, use "\x1a" instead
-         --> example.py:1:2
-          |
-        1 | ␈␛
-          |  ^
-          |
-        "#);
+        insta::assert_snapshot!(env.render(&diagnostic), @"error[invalid-character-sub]: Invalid unescaped character SUB, use \"\\x1a\" instead\n --> example.py:1:2\n  |\n1 | ␈\u{1a}␛\n  |  ^\n  |");
 
         Ok(())
     }
diff --git a/crates/ruff_db/src/parsed.rs b/crates/ruff_db/src/parsed.rs
index 227ec38..d1050d7 100644
--- a/crates/ruff_db/src/parsed.rs
+++ b/crates/ruff_db/src/parsed.rs
@@ -13,7 +13,7 @@
 
 use crate::Db;
 use crate::files::File;
-use crate::source::source_text;
+use crate::source::read_source_text;
 
 /// Returns the parsed AST of `file`, including its token stream.
 ///
@@ -21,7 +21,7 @@
 /// AST even if the file contains syntax errors. The parse errors
 /// are then accessible through [`Parsed::errors`].
 ///
-/// The query is only cached when the [`source_text()`] hasn't changed. This is because
+/// The query is only cached when the file contents haven't changed. This is because
 /// comparing two ASTs is a non-trivial operation and every offset change is directly
 /// reflected in the changed AST offsets.
 /// The other reason is that Ruff's AST doesn't implement `Eq` which Salsa requires
@@ -40,7 +40,7 @@
 }
 
 pub fn parsed_module_impl(db: &dyn Db, file: File) -> Parsed<ModModule> {
-    let source = source_text(db, file);
+    let source = read_source_text(db, file);
     let ty = file.source_type(db);
 
     let target_version = db.python_version();
diff --git a/crates/ruff_db/src/source.rs b/crates/ruff_db/src/source.rs
index 5a2d1b2..2033fad 100644
--- a/crates/ruff_db/src/source.rs
+++ b/crates/ruff_db/src/source.rs
@@ -16,6 +16,17 @@
 pub fn source_text(db: &dyn Db, file: File) -> SourceText {
     let path = file.path(db);
     let _span = tracing::trace_span!("source_text", file = %path).entered();
+
+    read_source_text(db, file)
+}
+
+/// Reads the source text without caching it as a Salsa query result.
+///
+/// This still records dependencies on the file's revision and source override.
+/// Use this when a query only needs the source text transiently and should not
+/// retain the full source in Salsa's query cache.
+pub fn read_source_text(db: &dyn Db, file: File) -> SourceText {
+    let path = file.path(db);
     let mut read_error = None;
 
     if let Some(source) = file.source_text_override(db) {
diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md
index d5f4789..b284399 100644
--- a/crates/ty/docs/rules.md
+++ b/crates/ty/docs/rules.md
@@ -584,7 +584,7 @@
 Default level: <a href="../../rules#rule-levels" title="This lint has a default level of 'error'."><code>error</code></a> ·
 Added in <a href="https://github.com/astral-sh/ty/releases/tag/0.0.1-alpha.1">0.0.1-alpha.1</a> ·
 <a href="https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20%22escape-character-in-forward-annotation%22" target="_blank">Related issues</a> ·
-<a href="https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fstring_annotation.rs#L106" target="_blank">View source</a>
+<a href="https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fstring_annotation.rs#L105" target="_blank">View source</a>
 </small>
 
 
@@ -705,7 +705,7 @@
 Default level: <a href="../../rules#rule-levels" title="This lint has a default level of 'error'."><code>error</code></a> ·
 Added in <a href="https://github.com/astral-sh/ty/releases/tag/0.0.1-alpha.1">0.0.1-alpha.1</a> ·
 <a href="https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20%22implicit-concatenated-string-type-annotation%22" target="_blank">Related issues</a> ·
-<a href="https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fstring_annotation.rs#L38" target="_blank">View source</a>
+<a href="https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fstring_annotation.rs#L37" target="_blank">View source</a>
 </small>
 
 
@@ -2220,7 +2220,7 @@
 Default level: <a href="../../rules#rule-levels" title="This lint has a default level of 'error'."><code>error</code></a> ·
 Added in <a href="https://github.com/astral-sh/ty/releases/tag/0.0.1-alpha.1">0.0.1-alpha.1</a> ·
 <a href="https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20%22invalid-syntax-in-forward-annotation%22" target="_blank">Related issues</a> ·
-<a href="https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fstring_annotation.rs#L63" target="_blank">View source</a>
+<a href="https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fstring_annotation.rs#L62" target="_blank">View source</a>
 </small>
 
 
@@ -3426,7 +3426,7 @@
 Default level: <a href="../../rules#rule-levels" title="This lint has a default level of 'error'."><code>error</code></a> ·
 Added in <a href="https://github.com/astral-sh/ty/releases/tag/0.0.1-alpha.1">0.0.1-alpha.1</a> ·
 <a href="https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20%22raw-string-type-annotation%22" target="_blank">Related issues</a> ·
-<a href="https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fstring_annotation.rs#L13" target="_blank">View source</a>
+<a href="https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fstring_annotation.rs#L12" target="_blank">View source</a>
 </small>
 
 
diff --git a/crates/ty_python_semantic/src/lib.rs b/crates/ty_python_semantic/src/lib.rs
index 09d01a4..346fb08 100644
--- a/crates/ty_python_semantic/src/lib.rs
+++ b/crates/ty_python_semantic/src/lib.rs
@@ -15,7 +15,7 @@
 use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticId, Severity, Span};
 use ruff_db::files::File;
 use ruff_db::parsed::parsed_module;
-use ruff_db::source::{SourceTextError, source_text};
+use ruff_db::source::{SourceTextError, read_source_text};
 use rustc_hash::FxHasher;
 pub use semantic_model::{
     Completion, ExpectedStringLiteralCompletion, HasDefinition, HasOptionalDefinition, HasType,
@@ -175,7 +175,7 @@
     let mut diagnostics: Vec<Diagnostic> = Vec::new();
 
     // Abort checking if there are IO errors.
-    let source = source_text(db, file);
+    let source = read_source_text(db, file);
 
     if let Some(read_error) = source.read_error() {
         return Err(IOErrorDiagnostic {
diff --git a/crates/ty_python_semantic/src/suppression.rs b/crates/ty_python_semantic/src/suppression.rs
index 21dba30..7f41cb1 100644
--- a/crates/ty_python_semantic/src/suppression.rs
+++ b/crates/ty_python_semantic/src/suppression.rs
@@ -8,7 +8,7 @@
 use ruff_db::diagnostic::{
     Annotation, Diagnostic, DiagnosticId, IntoDiagnosticMessage, LintName, Severity, Span,
 };
-use ruff_db::{files::File, parsed::parsed_module, source::source_text};
+use ruff_db::{files::File, parsed::parsed_module, source::read_source_text};
 use ruff_python_ast::token::TokenKind;
 use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
 
@@ -137,7 +137,7 @@
 #[salsa::tracked(returns(ref), heap_size=ruff_memory_usage::heap_size)]
 pub(crate) fn suppressions(db: &dyn Db, file: File) -> Suppressions {
     let parsed = parsed_module(db, file).load(db);
-    let source = source_text(db, file);
+    let source = read_source_text(db, file);
 
     let respect_type_ignore = db.analysis_settings(file).respect_type_ignore_comments;
 
diff --git a/crates/ty_python_semantic/src/types/context.rs b/crates/ty_python_semantic/src/types/context.rs
index 0f41eba..e40eb04 100644
--- a/crates/ty_python_semantic/src/types/context.rs
+++ b/crates/ty_python_semantic/src/types/context.rs
@@ -1,8 +1,10 @@
+use std::cell::OnceCell;
 use std::fmt;
 
 use drop_bomb::DebugDropBomb;
 use ruff_db::diagnostic::DiagnosticTag;
 use ruff_db::parsed::ParsedModuleRef;
+use ruff_db::source::{SourceText, read_source_text};
 use ruff_db::{
     diagnostic::{Annotation, Diagnostic, DiagnosticId, IntoDiagnosticMessage, Severity, Span},
     files::File,
@@ -42,6 +44,7 @@
     scope: ScopeId<'db>,
     file: File,
     module: &'ast ParsedModuleRef,
+    source_text: OnceCell<SourceText>,
     diagnostics: std::cell::RefCell<TypeCheckDiagnostics>,
     /// This field tracks various flags that control how type inference should behave in the current context.
     pub(crate) inference_flags: InferenceFlags,
@@ -55,6 +58,7 @@
             scope,
             module,
             file: scope.file(db),
+            source_text: OnceCell::new(),
             diagnostics: std::cell::RefCell::new(TypeCheckDiagnostics::default()),
             inference_flags: InferenceFlags::empty(),
             bomb: DebugDropBomb::new(
@@ -73,6 +77,11 @@
         self.module
     }
 
+    pub(crate) fn source_text(&self) -> &SourceText {
+        self.source_text
+            .get_or_init(|| read_source_text(self.db, self.file))
+    }
+
     pub(crate) fn scope(&self) -> ScopeId<'db> {
         self.scope
     }
diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs
index d58003e..1050d9b 100644
--- a/crates/ty_python_semantic/src/types/diagnostic.rs
+++ b/crates/ty_python_semantic/src/types/diagnostic.rs
@@ -33,7 +33,7 @@
 use crate::types::{KnownInstanceType, MemberLookupPolicy, TypedDictType, UnionType};
 use crate::{Db, DisplaySettings, FxIndexMap, Program, declare_lint};
 use itertools::Itertools;
-use ruff_db::source::source_text;
+use ruff_db::source::read_source_text;
 use ruff_db::{
     diagnostic::{Annotation, Diagnostic, Span, SubDiagnostic, SubDiagnosticSeverity},
     parsed::parsed_module,
@@ -5074,7 +5074,7 @@
     let file = function.file(db);
     let module = parsed_module(db, file).load(db);
     let node = implementation.node(db, file, &module);
-    let source_text = source_text(db, file);
+    let source_text = read_source_text(db, file);
 
     if policy == AbstractMethodAnnotationPolicy::ExcludeVerboseBody
         && source_text.line_start(node.name.end()) != source_text.line_start(node.end())
diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs
index 8fa5544..bc40a23 100644
--- a/crates/ty_python_semantic/src/types/display.rs
+++ b/crates/ty_python_semantic/src/types/display.rs
@@ -7,7 +7,7 @@
 use std::rc::Rc;
 
 use ruff_db::files::FilePath;
-use ruff_db::source::{line_index, source_text};
+use ruff_db::source::{line_index, read_source_text};
 use ruff_python_ast::str::{Quote, TripleQuotes};
 use ruff_python_literal::escape::AsciiEscape;
 use ruff_source_file::LineColumn;
@@ -699,7 +699,7 @@
         FilePath::Vendored(_) | FilePath::SystemVirtual(_) => Cow::Borrowed(path),
     };
     let line_index = line_index(db, file);
-    let LineColumn { line, column } = line_index.line_column(offset, &source_text(db, file));
+    let LineColumn { line, column } = line_index.line_column(offset, &read_source_text(db, file));
     f.set_invalid_type_annotation();
     write!(f, " @ {path}:{line}:{column}")
 }
diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs
index 6942fbd..1f211f1 100644
--- a/crates/ty_python_semantic/src/types/function.rs
+++ b/crates/ty_python_semantic/src/types/function.rs
@@ -55,7 +55,6 @@
 use ruff_db::diagnostic::{Annotation, DiagnosticId, Severity, Span};
 use ruff_db::files::{File, FileRange};
 use ruff_db::parsed::{ParsedModuleRef, parsed_module};
-use ruff_db::source::source_text;
 use ruff_diagnostics::{Edit, Fix};
 use ruff_python_ast::find_node::covering_node;
 use ruff_python_ast::{self as ast, OperatorPrecedence, ParameterWithDefault};
@@ -2333,7 +2332,7 @@
                                     let value_precedence = OperatorPrecedence::from_expr(value);
                                     OperatorPrecedence::from_expr_ref(parent) >= value_precedence
                                 });
-                            let value_text = &source_text(db, file)[value.range()];
+                            let value_text = &context.source_text()[value.range()];
                             let replacement = if needs_parens {
                                 format!("({value_text})")
                             } else {
diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs
index a7a2f8e..5f84e0e 100644
--- a/crates/ty_python_semantic/src/types/infer/builder.rs
+++ b/crates/ty_python_semantic/src/types/infer/builder.rs
@@ -4,7 +4,6 @@
 use itertools::Itertools;
 use ruff_db::files::File;
 use ruff_db::parsed::ParsedModuleRef;
-use ruff_db::source::source_text;
 use ruff_python_ast::helpers::is_dotted_name;
 use ruff_python_ast::name::Name;
 use ruff_python_ast::{
@@ -9611,8 +9610,7 @@
                                     ty = defined_type.display(self.db())
                                 ));
                                 if is_dotted_name(value) {
-                                    let source =
-                                        &source_text(self.db(), self.file())[value.range()];
+                                    let source = &self.context.source_text()[value.range()];
                                     diag.help(format_args!(
                                         "This error may indicate that `{source}` was defined as \
                                         `{source} = {special_form}` when \
diff --git a/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs b/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs
index 9579e30..4cf49b4 100644
--- a/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs
+++ b/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs
@@ -1,8 +1,5 @@
 use itertools::Itertools;
-use ruff_db::{
-    diagnostic::{Annotation, SubDiagnostic, SubDiagnosticSeverity},
-    source::source_text,
-};
+use ruff_db::diagnostic::{Annotation, SubDiagnostic, SubDiagnosticSeverity};
 use ruff_diagnostics::{Edit, Fix};
 use ruff_python_ast::{self as ast, name::Name};
 use ruff_text_size::{Ranged, TextRange, TextSize};
@@ -292,7 +289,7 @@
                             and use PEP 695 type variables",
                     );
                     if let ast::Expr::Subscript(node) = node {
-                        let source = source_text(db, context.file());
+                        let source = context.source_text();
                         let type_params_range = TextRange::new(
                             type_params.start().saturating_add(TextSize::new(1)),
                             type_params.end().saturating_sub(TextSize::new(1)),
@@ -475,7 +472,7 @@
                         && let Some(index) = *generic_index
                         && let [first_base, .., last_base] = class_node.bases()
                     {
-                        let source = source_text(db, context.file());
+                        let source = context.source_text();
                         let generic_base = &source[class_node.bases()[index].range()];
                         diagnostic.help(format_args!(
                             "Move `{generic_base}` to the end of the bases list"
diff --git a/crates/ty_python_semantic/src/types/string_annotation.rs b/crates/ty_python_semantic/src/types/string_annotation.rs
index 3a66b0d..5f86048 100644
--- a/crates/ty_python_semantic/src/types/string_annotation.rs
+++ b/crates/ty_python_semantic/src/types/string_annotation.rs
@@ -1,5 +1,4 @@
 use ruff_db::parsed::parsed_string_annotation;
-use ruff_db::source::source_text;
 use ruff_python_ast::{self as ast, ModExpression, StringFlags};
 use ruff_python_parser::{ParseError, ParseErrorType, Parsed};
 use ruff_text_size::Ranged;
@@ -129,12 +128,11 @@
     string_expr: &ast::ExprStringLiteral,
 ) -> Option<Parsed<ModExpression>> {
     let file = context.file();
-    let db = context.db();
 
     let _span = tracing::trace_span!("parse_string_annotation", string=?string_expr.range(), ?file)
         .entered();
 
-    let source = source_text(db, file);
+    let source = context.source_text();
 
     if let Some(string_literal) = string_expr.as_single_part_string() {
         let prefix = string_literal.flags.prefix();