Skip to main content

code_system_graph_core/
data_contracts.rs

1//! Bounded, evidence-first extraction of database declarations and literal SQL access.
2//!
3//! Extracted documents contain names and structural metadata only. SQL bodies, default values,
4//! connection strings, credentials, and other source literals are never retained.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10use sqlparser::ast::{
11    AlterTableOperation, ColumnDef, ColumnOption, Expr, FromTable, IndexColumn, ObjectName, Query, SetExpr, Statement, TableConstraint, TableFactor, TableObject, TableWithJoins
12};
13use sqlparser::dialect::{GenericDialect, MySqlDialect};
14use sqlparser::parser::Parser;
15use thiserror::Error;
16use tree_sitter::Node as SyntaxNode;
17
18use crate::SourceLanguage;
19
20/// Maximum source size accepted by the database artifact extractor.
21pub const MAX_DATA_INPUT_BYTES: usize = 1_048_576;
22/// Maximum number of source lines accepted by the database artifact extractor.
23pub const MAX_DATA_SOURCE_LINES: u32 = 100_000;
24/// Maximum number of structured items retained in one document.
25pub const MAX_DATA_ITEMS: usize = 4_096;
26/// Maximum nesting depth followed in SQL and framework declarations.
27pub const MAX_DATA_DEPTH: usize = 32;
28
29const MAX_IDENTIFIER_CHARS: usize = 512;
30const MAX_LITERAL_SQL_BYTES: usize = 65_536;
31
32/// Kind of source artifact represented by a [`DataDocument`].
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum DataArtifactKind {
36    /// Ordered SQL migration.
37    SqlMigration,
38    /// Declarative SQL schema such as `schema.sql`.
39    DeclarativeSqlSchema,
40    /// Prisma schema.
41    Prisma,
42    /// Alembic Python migration.
43    Alembic,
44    /// `SQLAlchemy` declarative model source.
45    SqlAlchemy,
46    /// Diesel `table!` declaration source.
47    Diesel,
48    /// Standalone SQL query consumed by source code, including `SQLx` `query_file*!` macros.
49    SqlQueryFile,
50    /// `SQLx` project configuration (`sqlx.toml`).
51    SqlxConfiguration,
52    /// Focused source-language file containing literal SQL calls.
53    LiteralQuerySource,
54}
55
56/// Data-access library whose API supplied the direct source evidence.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum DataFramework {
60    /// The `transact-rs/sqlx` Rust SQL toolkit.
61    Sqlx,
62    /// The `blackbeam/mysql_async` asynchronous `MySQL` client.
63    MysqlAsync,
64    /// The `PyMySQL` implementation of Python's DB-API.
65    PyMysql,
66    /// The psycopg and psycopg2 `PostgreSQL` adapters.
67    Psycopg,
68    /// The `SQLAlchemy` Python ORM.
69    SqlAlchemy,
70}
71
72/// Kind of external data artifact referenced by source code.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum DataArtifactReferenceKind {
76    /// A `SQLx` `query_file*!` query file.
77    QueryFile,
78    /// A `SQLx` embedded or runtime migration directory.
79    MigrationDirectory,
80    /// The migration directory selected by `sqlx::migrate!()` with no explicit path.
81    SqlxDefaultMigrationDirectory,
82}
83
84/// Relationship between source code and a database object.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum DataAccessRole {
88    /// Reads rows from a directly named table.
89    Reader,
90    /// Writes rows to a directly named table.
91    Writer,
92    /// Binds an ORM model to a table.
93    ModelBinding,
94    /// Declares a table or table member.
95    Declaration,
96}
97
98/// SQL operation proven by a complete literal statement.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum DataOperation {
102    /// `SELECT` or a read-only query expression.
103    Select,
104    /// `INSERT`.
105    Insert,
106    /// `UPDATE`.
107    Update,
108    /// `DELETE`.
109    Delete,
110}
111
112/// Machine-readable extraction limitation.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum DataWarning {
116    /// A query was computed or concatenated and therefore remains unlinked.
117    DynamicQuery,
118    /// A syntactic construct was outside the focused extraction subset.
119    UnsupportedConstruct,
120    /// A declaration referenced an object that could not be resolved locally.
121    UnresolvedReference,
122    /// A bounded parser stopped at its nesting or item budget.
123    LimitExceeded,
124    /// A literal looked like SQL but did not parse as a supported statement.
125    SqlParseRecovery,
126}
127
128/// One-based source line constrained to the extraction line budget.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
130#[serde(transparent)]
131pub struct DataEvidenceLine(u32);
132
133impl DataEvidenceLine {
134    /// Creates a bounded one-based evidence line.
135    ///
136    /// # Errors
137    ///
138    /// Returns [`DataExtractionError::InvalidEvidenceLine`] for zero or a line beyond the
139    /// configured source-line limit.
140    pub fn new(line: u32) -> Result<Self, DataExtractionError> {
141        if line == 0 || line > MAX_DATA_SOURCE_LINES {
142            return Err(DataExtractionError::InvalidEvidenceLine { line });
143        }
144        Ok(Self(line))
145    }
146
147    /// Returns the one-based line number.
148    #[must_use]
149    pub const fn get(self) -> u32 {
150        self.0
151    }
152}
153
154/// One database column declaration without default literal contents.
155#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
156pub struct DatabaseColumn {
157    /// Database column name.
158    pub name: String,
159    /// Declared type, when statically available.
160    pub data_type: Option<String>,
161    /// Explicit nullability, when available.
162    pub nullable: Option<bool>,
163    /// Whether the declaration marks the column as a primary key.
164    pub primary_key: bool,
165    /// Whether the declaration marks the column as unique.
166    pub unique: bool,
167    /// Whether a default exists; its value is intentionally discarded.
168    pub default_present: bool,
169    /// Direct declaration line.
170    pub evidence: DataEvidenceLine,
171}
172
173/// One database index declaration.
174#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
175pub struct DatabaseIndex {
176    /// Index name, when explicitly declared.
177    pub name: Option<String>,
178    /// Directly named indexed columns.
179    pub columns: Vec<String>,
180    /// Whether this is a unique index.
181    pub unique: bool,
182    /// Direct declaration line.
183    pub evidence: DataEvidenceLine,
184}
185
186/// One database foreign-key declaration.
187#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
188pub struct DatabaseForeignKey {
189    /// Constraint name, when explicitly declared.
190    pub name: Option<String>,
191    /// Local columns participating in the key.
192    pub columns: Vec<String>,
193    /// Directly named referenced table.
194    pub referenced_table: String,
195    /// Directly named referenced columns.
196    pub referenced_columns: Vec<String>,
197    /// Direct declaration line.
198    pub evidence: DataEvidenceLine,
199}
200
201/// Structured table definition collected from SQL or an ORM declaration.
202#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
203pub struct DatabaseTable {
204    /// Explicit database or catalog qualifier.
205    pub database: Option<String>,
206    /// Explicit schema qualifier.
207    pub schema: Option<String>,
208    /// Unqualified table name.
209    pub name: String,
210    /// Deterministically ordered column declarations.
211    pub columns: Vec<DatabaseColumn>,
212    /// Deterministically ordered index declarations.
213    pub indexes: Vec<DatabaseIndex>,
214    /// Deterministically ordered foreign keys.
215    pub foreign_keys: Vec<DatabaseForeignKey>,
216    /// Direct table declaration line.
217    pub evidence: DataEvidenceLine,
218}
219
220/// Ordering and ancestry metadata for one migration artifact.
221#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
222pub struct MigrationMetadata {
223    /// Framework revision identifier, when explicitly declared.
224    pub revision: Option<String>,
225    /// Direct predecessor revision, when explicitly declared.
226    pub down_revision: Option<String>,
227    /// Leading numeric filename hint, when present.
228    pub order_hint: Option<u64>,
229    /// Whether an explicit downgrade entry point was observed.
230    pub reversible: bool,
231    /// Direct metadata or first-source line.
232    pub evidence: DataEvidenceLine,
233}
234
235/// One directly evidenced declaration, model binding, or literal SQL access.
236#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
237pub struct DataAccessObservation {
238    /// Relationship to the database object.
239    pub role: DataAccessRole,
240    /// Directly named table; absent observations are never emitted for readers or writers.
241    pub table: String,
242    /// ORM model name when the table is resolved through an explicit model binding.
243    pub model: Option<String>,
244    /// Enclosing model, function, or method when statically available.
245    pub owner: Option<String>,
246    /// Literal SQL operation for readers and writers.
247    pub operation: Option<DataOperation>,
248    /// Direct source line.
249    pub evidence: DataEvidenceLine,
250}
251
252/// One statically resolved source-to-data-artifact reference.
253#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
254pub struct DataArtifactReference {
255    /// Framework whose API declared the reference.
256    pub framework: DataFramework,
257    /// Whether the target is one query file or a migration directory.
258    pub kind: DataArtifactReferenceKind,
259    /// Normalized repository-relative target path.
260    pub path: String,
261    /// Enclosing function or method when statically available.
262    pub owner: Option<String>,
263    /// Direct source line containing the reference.
264    pub evidence: DataEvidenceLine,
265}
266
267/// Owned, serialization-ready output for one database-related source artifact.
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269pub struct DataDocument {
270    /// Repository-relative source path.
271    pub source_path: String,
272    /// Artifact syntax selected from the path, filename, or source-language API.
273    pub artifact_kind: DataArtifactKind,
274    /// Explicit database or catalog name, when unambiguous.
275    pub database_name: Option<String>,
276    /// Explicit schema name, when unambiguous.
277    pub schema_name: Option<String>,
278    /// Deterministically sorted and merged table definitions.
279    pub tables: Vec<DatabaseTable>,
280    /// Migration ancestry and ordering hints.
281    pub migration: Option<MigrationMetadata>,
282    /// Deterministically sorted direct observations.
283    pub accesses: Vec<DataAccessObservation>,
284    /// Data-access libraries directly recognized in this artifact.
285    pub frameworks: Vec<DataFramework>,
286    /// Statically resolved references to query files or migration directories.
287    pub references: Vec<DataArtifactReference>,
288    /// Enclosing model or callable names.
289    pub owners: Vec<String>,
290    /// Deterministically sorted extraction limitations.
291    pub warnings: Vec<DataWarning>,
292    /// Whether dynamic, unsupported, malformed, or budget-limited input was observed.
293    pub incomplete: bool,
294}
295
296/// Failure returned by bounded database artifact extraction.
297#[derive(Debug, Error, PartialEq, Eq)]
298pub enum DataExtractionError {
299    /// Input exceeds the fixed byte budget.
300    #[error("database artifact input exceeds the byte limit ({actual} > {maximum})")]
301    InputTooLarge {
302        /// Observed bytes.
303        actual: usize,
304        /// Maximum accepted bytes.
305        maximum: usize,
306    },
307    /// Input exceeds the fixed source-line budget.
308    #[error("database artifact input exceeds the line limit ({actual} > {maximum})")]
309    TooManyLines {
310        /// Observed source lines.
311        actual: u32,
312        /// Maximum accepted source lines.
313        maximum: u32,
314    },
315    /// The path does not select a supported database artifact.
316    #[error("unsupported database artifact filename or extension")]
317    UnsupportedArtifact,
318    /// SQL parsing failed without exposing parser input or literal values.
319    #[error("SQL artifact could not be parsed")]
320    SqlParseFailed,
321    /// A bounded collection exceeded its item budget.
322    #[error("database artifact exceeds the structured item limit")]
323    ItemLimitExceeded,
324    /// Evidence must fit the one-based line budget.
325    #[error("evidence line {line} is outside the supported range")]
326    InvalidEvidenceLine {
327        /// Rejected line number.
328        line: u32,
329    },
330}
331
332/// Extracts a supported SQL, Prisma, Alembic, `SQLAlchemy`, or Diesel artifact.
333///
334/// Dispatch is path-only: `.sql`, `.prisma`, Alembic migration paths, conventional `SQLAlchemy`
335/// model filenames, and Diesel's exact `schema.rs` filename. Source contents never change the
336/// selected parser.
337///
338/// # Errors
339///
340/// Returns [`DataExtractionError`] for unsupported paths. Oversized inputs and structured output
341/// degrade to bounded incomplete documents so one large dump cannot abort a workspace scan.
342/// Over-budget inputs and unsupported SQL dialect constructs produce an incomplete source-free
343/// document rather than aborting the workspace scan.
344pub fn extract_data_artifact(
345    source_path: &str,
346    input: &str,
347) -> Result<DataDocument, DataExtractionError> {
348    let kind = artifact_kind(source_path, input).ok_or(DataExtractionError::UnsupportedArtifact)?;
349    if validate_input(input).is_err() {
350        let mut document = empty_document(source_path, kind);
351        mark_incomplete(&mut document, DataWarning::LimitExceeded);
352        return Ok(document);
353    }
354    let mut document = match kind {
355        DataArtifactKind::SqlMigration
356        | DataArtifactKind::DeclarativeSqlSchema
357        | DataArtifactKind::SqlQueryFile => parse_sql_artifact(source_path, input, kind),
358        DataArtifactKind::SqlxConfiguration => parse_sqlx_configuration(source_path, input),
359        DataArtifactKind::Prisma => parse_prisma(source_path, input),
360        DataArtifactKind::Alembic => parse_alembic(source_path, input),
361        DataArtifactKind::SqlAlchemy => parse_sqlalchemy(source_path, input),
362        DataArtifactKind::Diesel => parse_diesel(source_path, input),
363        DataArtifactKind::LiteralQuerySource => {
364            return Err(DataExtractionError::UnsupportedArtifact);
365        }
366    };
367    finish_document(&mut document);
368    Ok(document)
369}
370
371/// Extracts focused literal SQL calls from a supported source language.
372///
373/// Dynamic, interpolated, concatenated, and unparseable query expressions are marked incomplete
374/// and remain unlinked. The function intentionally returns a document rather than an error so it
375/// can degrade safely inside focused source extractors.
376#[must_use]
377pub fn parse_literal_sql_source(
378    language: SourceLanguage,
379    source_path: &str,
380    input: &str,
381) -> DataDocument {
382    parse_literal_sql_source_at_root(
383        language,
384        source_path,
385        &inferred_crate_root(source_path),
386        input,
387    )
388}
389
390/// Extracts literal SQL calls using an explicit repository-relative Cargo crate root.
391///
392/// The crate root is used to resolve `SQLx` `query_file*!` and `migrate!` paths, which `SQLx`
393/// defines relative to the directory containing the crate's `Cargo.toml`.
394#[must_use]
395pub fn parse_literal_sql_source_at_root(
396    language: SourceLanguage,
397    source_path: &str,
398    crate_root: &str,
399    input: &str,
400) -> DataDocument {
401    let mut document = empty_document(source_path, DataArtifactKind::LiteralQuerySource);
402    if validate_input(input).is_err() {
403        mark_incomplete(&mut document, DataWarning::LimitExceeded);
404        return document;
405    }
406
407    if language == SourceLanguage::Rust {
408        extract_rust_database_source(input, crate_root, &mut document);
409    } else if language == SourceLanguage::Python {
410        if input.contains("pymysql") {
411            document.frameworks.push(DataFramework::PyMysql);
412        }
413        if input.contains("psycopg") {
414            document.frameworks.push(DataFramework::Psycopg);
415        }
416        if input.contains("sqlalchemy") || input.contains(".query(") {
417            extract_sqlalchemy_accesses(input, &mut document);
418        }
419    }
420    let literals = quoted_literals(input, language);
421    if literals.len() >= MAX_DATA_ITEMS {
422        mark_incomplete(&mut document, DataWarning::LimitExceeded);
423    }
424    for literal in literals {
425        if literal.value.len() > MAX_LITERAL_SQL_BYTES || !has_query_context(input, &literal) {
426            continue;
427        }
428        let query = if language == SourceLanguage::Python
429            && (literal.interpolated || python_format_call_after(input, &literal))
430        {
431            mark_incomplete(&mut document, DataWarning::DynamicQuery);
432            sanitize_python_f_string(&literal.value)
433        } else if literal.dynamic {
434            mark_incomplete(&mut document, DataWarning::DynamicQuery);
435            None
436        } else {
437            Some(literal.value.clone())
438        };
439        let Some(query) = query else {
440            continue;
441        };
442        let first_keyword = first_sql_keyword(&query);
443        if !matches!(
444            first_keyword.as_deref(),
445            Some("SELECT" | "WITH" | "INSERT" | "UPDATE" | "DELETE")
446        ) {
447            continue;
448        }
449        let Some(statements) = parse_sql_with_supported_dialects(&query) else {
450            mark_incomplete(&mut document, DataWarning::SqlParseRecovery);
451            continue;
452        };
453        let owner = owner_at_line(language, input, literal.line);
454        if let Some(owner) = owner.as_ref() {
455            document.owners.push(owner.clone());
456        }
457        for statement in &statements {
458            append_statement_accesses(
459                statement,
460                evidence(literal.line),
461                owner.as_deref(),
462                &mut document,
463                0,
464            );
465        }
466        document
467            .accesses
468            .retain(|access| !access.table.contains("__csg_dynamic_value__"));
469    }
470
471    if (language != SourceLanguage::Rust || document.frameworks.is_empty())
472        && source_has_dynamic_query(input, language)
473    {
474        mark_incomplete(&mut document, DataWarning::DynamicQuery);
475    }
476    finish_document(&mut document);
477    document
478}
479
480fn validate_input(input: &str) -> Result<(), DataExtractionError> {
481    if input.len() > MAX_DATA_INPUT_BYTES {
482        return Err(DataExtractionError::InputTooLarge {
483            actual: input.len(),
484            maximum: MAX_DATA_INPUT_BYTES,
485        });
486    }
487    let lines = input.lines().count().max(1);
488    let lines = u32::try_from(lines).unwrap_or(u32::MAX);
489    if lines > MAX_DATA_SOURCE_LINES {
490        return Err(DataExtractionError::TooManyLines {
491            actual: lines,
492            maximum: MAX_DATA_SOURCE_LINES,
493        });
494    }
495    Ok(())
496}
497
498fn artifact_kind(source_path: &str, input: &str) -> Option<DataArtifactKind> {
499    let path = Path::new(source_path);
500    let filename = path.file_name()?.to_str()?.to_ascii_lowercase();
501    let extension = path.extension()?.to_str()?.to_ascii_lowercase();
502    let normalized = source_path.replace('\\', "/").to_ascii_lowercase();
503    match (filename.as_str(), extension.as_str()) {
504        ("sqlx.toml", "toml") => Some(DataArtifactKind::SqlxConfiguration),
505        ("schema.sql" | "structure.sql" | "init.sql", "sql") => {
506            Some(DataArtifactKind::DeclarativeSqlSchema)
507        }
508        (_, "sql") if sql_migration_path(&normalized, &filename) => {
509            Some(DataArtifactKind::SqlMigration)
510        }
511        (_, "sql") if sql_contains_only_queries(input) => Some(DataArtifactKind::SqlQueryFile),
512        (_, "sql") => Some(DataArtifactKind::DeclarativeSqlSchema),
513        (_, "prisma") => Some(DataArtifactKind::Prisma),
514        ("schema.rs", "rs") => Some(DataArtifactKind::Diesel),
515        (_, "py")
516            if normalized.contains("/alembic/")
517                || normalized.contains("/versions/")
518                || normalized.contains("/migrations/") =>
519        {
520            Some(DataArtifactKind::Alembic)
521        }
522        ("model.py" | "models.py" | "entities.py", "py") => Some(DataArtifactKind::SqlAlchemy),
523        _ => None,
524    }
525}
526
527fn empty_document(source_path: &str, artifact_kind: DataArtifactKind) -> DataDocument {
528    DataDocument {
529        source_path: source_path.to_owned(),
530        artifact_kind,
531        database_name: None,
532        schema_name: None,
533        tables: Vec::new(),
534        migration: None,
535        accesses: Vec::new(),
536        frameworks: Vec::new(),
537        references: Vec::new(),
538        owners: Vec::new(),
539        warnings: Vec::new(),
540        incomplete: false,
541    }
542}
543
544fn parse_sql_artifact(source_path: &str, input: &str, kind: DataArtifactKind) -> DataDocument {
545    let mut document = empty_document(source_path, kind);
546    if let Some(statements) = parse_sql_with_supported_dialects(input) {
547        let starts = sql_statement_lines(input);
548        for (index, statement) in statements.iter().enumerate() {
549            append_sql_artifact_statement(
550                statement,
551                starts.get(index).copied().unwrap_or(1),
552                kind,
553                &mut document,
554            );
555        }
556    } else {
557        mark_incomplete(&mut document, DataWarning::SqlParseRecovery);
558        for chunk in sql_statement_chunks(input) {
559            if !sql_chunk_is_relevant(chunk.text, kind) {
560                continue;
561            }
562            let Some(statements) = parse_sql_with_supported_dialects(chunk.text) else {
563                continue;
564            };
565            for statement in &statements {
566                append_sql_artifact_statement(statement, chunk.line, kind, &mut document);
567            }
568        }
569    }
570
571    if kind == DataArtifactKind::SqlMigration {
572        document.migration = Some(sql_migration_metadata(source_path, input));
573    }
574    document
575}
576
577fn parse_sql_with_supported_dialects(input: &str) -> Option<Vec<Statement>> {
578    Parser::parse_sql(&GenericDialect {}, input)
579        .or_else(|_| Parser::parse_sql(&MySqlDialect {}, input))
580        .ok()
581}
582
583fn append_sql_artifact_statement(
584    statement: &Statement,
585    line: u32,
586    kind: DataArtifactKind,
587    document: &mut DataDocument,
588) {
589    if kind == DataArtifactKind::SqlQueryFile {
590        append_statement_accesses(statement, evidence(line), None, document, 0);
591    } else if !append_schema_statement(statement, evidence(line), document) {
592        mark_incomplete(document, DataWarning::UnsupportedConstruct);
593    }
594}
595
596fn sql_chunk_is_relevant(input: &str, kind: DataArtifactKind) -> bool {
597    let Some(keyword) = first_sql_keyword(input) else {
598        return false;
599    };
600    if kind == DataArtifactKind::SqlQueryFile {
601        matches!(
602            keyword.as_str(),
603            "SELECT" | "WITH" | "INSERT" | "UPDATE" | "DELETE"
604        )
605    } else {
606        matches!(keyword.as_str(), "CREATE" | "ALTER")
607    }
608}
609
610fn parse_sqlx_configuration(source_path: &str, input: &str) -> DataDocument {
611    let mut document = empty_document(source_path, DataArtifactKind::SqlxConfiguration);
612    document.frameworks.push(DataFramework::Sqlx);
613    let crate_root = Path::new(source_path)
614        .parent()
615        .and_then(Path::to_str)
616        .unwrap_or_default()
617        .replace('\\', "/");
618    let mut section = "";
619
620    for (index, source_line) in input.lines().enumerate() {
621        let line = strip_toml_comment(source_line).trim();
622        if line.starts_with('[') && line.ends_with(']') {
623            section = line[1..line.len() - 1].trim();
624            continue;
625        }
626        if section != "migrate" {
627            continue;
628        }
629        let Some((key, value)) = line.split_once('=') else {
630            continue;
631        };
632        if key.trim() != "migrations-dir" {
633            continue;
634        }
635        let Some(value) = toml_string(value.trim()) else {
636            mark_incomplete(&mut document, DataWarning::UnresolvedReference);
637            continue;
638        };
639        push_sqlx_reference(
640            DataArtifactReferenceKind::MigrationDirectory,
641            value,
642            evidence(u32::try_from(index + 1).unwrap_or(u32::MAX)),
643            &crate_root,
644            None,
645            &mut document,
646        );
647    }
648    document
649}
650
651fn strip_toml_comment(line: &str) -> &str {
652    let mut quote = None;
653    let mut escaped = false;
654    for (index, character) in line.char_indices() {
655        if escaped {
656            escaped = false;
657            continue;
658        }
659        match character {
660            '\\' if quote == Some('"') => escaped = true,
661            '\'' | '"' if quote == Some(character) => quote = None,
662            '\'' | '"' if quote.is_none() => quote = Some(character),
663            '#' if quote.is_none() => return &line[..index],
664            _ => {}
665        }
666    }
667    line
668}
669
670fn toml_string(value: &str) -> Option<&str> {
671    let quote = value.chars().next()?;
672    if !matches!(quote, '\'' | '"') || !value.ends_with(quote) || value.len() < 2 {
673        return None;
674    }
675    Some(&value[1..value.len() - 1])
676}
677
678fn append_schema_statement(
679    statement: &Statement,
680    line: DataEvidenceLine,
681    document: &mut DataDocument,
682) -> bool {
683    match statement {
684        Statement::CreateTable(create) => {
685            let mut table = table_from_name(&create.name, line);
686            table.columns = create
687                .columns
688                .iter()
689                .map(|column| sql_column(column, line))
690                .collect();
691            apply_table_constraints(&mut table, &create.constraints, line);
692            push_declaration_access(document, &table, None);
693            document.tables.push(table);
694        }
695        Statement::CreateIndex(index) => {
696            let table = ensure_table(document, &index.table_name, line);
697            table.indexes.push(DatabaseIndex {
698                name: index.name.as_ref().map(object_name),
699                columns: index.columns.iter().filter_map(index_column_name).collect(),
700                unique: index.unique,
701                evidence: line,
702            });
703        }
704        Statement::AlterTable(alter) => {
705            let table = ensure_table(document, &alter.name, line);
706            for operation in &alter.operations {
707                apply_alter_operation(table, operation, line);
708            }
709        }
710        _ => return false,
711    }
712    true
713}
714
715fn sql_column(column: &ColumnDef, line: DataEvidenceLine) -> DatabaseColumn {
716    let mut nullable = None;
717    let mut primary_key = false;
718    let mut unique = false;
719    let mut default_present = false;
720    for option in &column.options {
721        match &option.option {
722            ColumnOption::Null => nullable = Some(true),
723            ColumnOption::NotNull => nullable = Some(false),
724            ColumnOption::PrimaryKey(_) => primary_key = true,
725            ColumnOption::Unique(_) => unique = true,
726            ColumnOption::Default(_)
727            | ColumnOption::Materialized(_)
728            | ColumnOption::Generated { .. }
729            | ColumnOption::Identity(_) => default_present = true,
730            _ => {}
731        }
732    }
733    DatabaseColumn {
734        name: bounded_identifier(&column.name.value),
735        data_type: Some(safe_data_type(&column.data_type.to_string())),
736        nullable,
737        primary_key,
738        unique,
739        default_present,
740        evidence: line,
741    }
742}
743
744fn apply_table_constraints(
745    table: &mut DatabaseTable,
746    constraints: &[TableConstraint],
747    line: DataEvidenceLine,
748) {
749    for constraint in constraints {
750        match constraint {
751            TableConstraint::PrimaryKey(primary) => {
752                let columns = primary
753                    .columns
754                    .iter()
755                    .filter_map(index_column_name)
756                    .collect::<Vec<_>>();
757                mark_columns(table, &columns, true, false);
758            }
759            TableConstraint::Unique(unique) => {
760                let columns = unique
761                    .columns
762                    .iter()
763                    .filter_map(index_column_name)
764                    .collect::<Vec<_>>();
765                mark_columns(table, &columns, false, true);
766                table.indexes.push(DatabaseIndex {
767                    name: unique
768                        .name
769                        .as_ref()
770                        .or(unique.index_name.as_ref())
771                        .map(|name| bounded_identifier(&name.value)),
772                    columns,
773                    unique: true,
774                    evidence: line,
775                });
776            }
777            TableConstraint::ForeignKey(foreign) => {
778                table.foreign_keys.push(DatabaseForeignKey {
779                    name: foreign
780                        .name
781                        .as_ref()
782                        .map(|name| bounded_identifier(&name.value)),
783                    columns: foreign
784                        .columns
785                        .iter()
786                        .map(|name| bounded_identifier(&name.value))
787                        .collect(),
788                    referenced_table: object_name(&foreign.foreign_table),
789                    referenced_columns: foreign
790                        .referred_columns
791                        .iter()
792                        .map(|name| bounded_identifier(&name.value))
793                        .collect(),
794                    evidence: line,
795                });
796            }
797            TableConstraint::Index(index) => {
798                table.indexes.push(DatabaseIndex {
799                    name: index
800                        .name
801                        .as_ref()
802                        .map(|name| bounded_identifier(&name.value)),
803                    columns: index.columns.iter().filter_map(index_column_name).collect(),
804                    unique: false,
805                    evidence: line,
806                });
807            }
808            _ => {}
809        }
810    }
811}
812
813fn apply_alter_operation(
814    table: &mut DatabaseTable,
815    operation: &AlterTableOperation,
816    line: DataEvidenceLine,
817) {
818    match operation {
819        AlterTableOperation::AddColumn { column_def, .. } => {
820            table.columns.push(sql_column(column_def, line));
821        }
822        AlterTableOperation::AddConstraint { constraint, .. } => {
823            apply_table_constraints(table, std::slice::from_ref(constraint), line);
824        }
825        _ => {}
826    }
827}
828
829fn parse_prisma(source_path: &str, input: &str) -> DataDocument {
830    let mut document = empty_document(source_path, DataArtifactKind::Prisma);
831    for block in named_blocks(input, "model") {
832        let model = bounded_identifier(block.name);
833        let mapped = find_call_literal(block.body, "@@map").unwrap_or_else(|| model.clone());
834        let schema = find_call_literal(block.body, "@@schema");
835        let mut table = table_from_qualified_text(&mapped, evidence(block.line));
836        table.schema = schema.map(|value| bounded_identifier(&value));
837
838        for (offset, raw_line) in block.body.lines().enumerate() {
839            let line = add_line(block.line, offset);
840            let trimmed = raw_line.trim();
841            if trimmed.is_empty()
842                || trimmed.starts_with("//")
843                || trimmed.starts_with("@@")
844                || trimmed.starts_with('}')
845            {
846                continue;
847            }
848            let fields = trimmed.split_whitespace().collect::<Vec<_>>();
849            if fields.len() < 2 {
850                continue;
851            }
852            let field_name =
853                find_call_literal(trimmed, "@map").unwrap_or_else(|| bounded_identifier(fields[0]));
854            let field_type = fields[1];
855            if field_type.starts_with(char::is_uppercase) && trimmed.contains("@relation") {
856                if let Some(foreign_key) = prisma_foreign_key(trimmed, field_type, evidence(line)) {
857                    table.foreign_keys.push(foreign_key);
858                }
859                continue;
860            }
861            table.columns.push(DatabaseColumn {
862                name: bounded_identifier(&field_name),
863                data_type: Some(bounded_identifier(
864                    field_type.trim_end_matches('?').trim_end_matches("[]"),
865                )),
866                nullable: Some(field_type.ends_with('?')),
867                primary_key: trimmed.contains("@id"),
868                unique: trimmed.contains("@unique"),
869                default_present: trimmed.contains("@default("),
870                evidence: evidence(line),
871            });
872        }
873        document.owners.push(model.clone());
874        document.accesses.push(DataAccessObservation {
875            role: DataAccessRole::ModelBinding,
876            table: qualified_table_name(&table),
877            model: None,
878            owner: Some(model),
879            operation: None,
880            evidence: table.evidence,
881        });
882        document.tables.push(table);
883    }
884    document
885}
886
887fn prisma_foreign_key(
888    line: &str,
889    referenced_model: &str,
890    evidence: DataEvidenceLine,
891) -> Option<DatabaseForeignKey> {
892    let fields = bracket_values_after(line, "fields:")?;
893    let references = bracket_values_after(line, "references:").unwrap_or_default();
894    Some(DatabaseForeignKey {
895        name: None,
896        columns: fields,
897        referenced_table: bounded_identifier(referenced_model.trim_end_matches('?')),
898        referenced_columns: references,
899        evidence,
900    })
901}
902
903fn parse_alembic(source_path: &str, input: &str) -> DataDocument {
904    let mut document = empty_document(source_path, DataArtifactKind::Alembic);
905    document.migration = Some(MigrationMetadata {
906        revision: assignment_literal(input, "revision"),
907        down_revision: assignment_literal(input, "down_revision"),
908        order_hint: filename_order_hint(source_path),
909        reversible: input
910            .lines()
911            .any(|line| line.trim_start().starts_with("def downgrade(")),
912        evidence: evidence(assignment_line(input, "revision").unwrap_or(1)),
913    });
914
915    for call in collect_calls(input, "op.create_table(") {
916        let Some(name) = first_quoted(&call.body) else {
917            mark_incomplete(&mut document, DataWarning::DynamicQuery);
918            continue;
919        };
920        let mut table = table_from_qualified_text(&name, evidence(call.line));
921        for column_call in collect_calls(&call.body, "sa.Column(") {
922            if let Some(column) =
923                python_column(&column_call.body, add_evidence(call.line, column_call.line))
924            {
925                table.columns.push(column);
926            }
927        }
928        for foreign_call in collect_calls(&call.body, "sa.ForeignKeyConstraint(") {
929            if let Some(foreign) = python_foreign_key(
930                &foreign_call.body,
931                add_evidence(call.line, foreign_call.line),
932            ) {
933                table.foreign_keys.push(foreign);
934            }
935        }
936        push_declaration_access(&mut document, &table, Some("upgrade"));
937        document.tables.push(table);
938    }
939
940    for call in collect_calls(input, "op.add_column(") {
941        let quoted = quoted_values(&call.body);
942        let Some(table_name) = quoted.first() else {
943            mark_incomplete(&mut document, DataWarning::DynamicQuery);
944            continue;
945        };
946        let table = ensure_text_table(&mut document, table_name, evidence(call.line));
947        if let Some(column_call) = collect_calls(&call.body, "sa.Column(").first()
948            && let Some(column) = python_column(&column_call.body, evidence(call.line))
949        {
950            table.columns.push(column);
951        }
952    }
953
954    for call in collect_calls(input, "op.create_index(") {
955        let quoted = quoted_values(&call.body);
956        if quoted.len() < 2 {
957            mark_incomplete(&mut document, DataWarning::DynamicQuery);
958            continue;
959        }
960        let columns = bracket_values(&call.body).unwrap_or_default();
961        let unique = call.body.split_whitespace().any(|part| {
962            part.trim_matches(|character: char| character == ',' || character == ')')
963                == "unique=True"
964        });
965        let table = ensure_text_table(&mut document, &quoted[1], evidence(call.line));
966        table.indexes.push(DatabaseIndex {
967            name: Some(bounded_identifier(&quoted[0])),
968            columns,
969            unique,
970            evidence: evidence(call.line),
971        });
972    }
973    document
974}
975
976fn parse_sqlalchemy(source_path: &str, input: &str) -> DataDocument {
977    let mut document = empty_document(source_path, DataArtifactKind::SqlAlchemy);
978    for class in python_classes(input) {
979        let Some(table_name) = assignment_literal(class.body, "__tablename__") else {
980            continue;
981        };
982        let mut table = table_from_qualified_text(&table_name, evidence(class.line));
983        table.schema = table_argument_literal(class.body, "schema");
984        for (offset, line) in class.body.lines().enumerate() {
985            let Some(column_position) = line.find("Column(") else {
986                continue;
987            };
988            let Some((attribute, _)) = line[..column_position].split_once('=') else {
989                continue;
990            };
991            let open = column_position + "Column(".len();
992            let body = balanced_slice(&line[open..]).unwrap_or(&line[open..]);
993            if let Some(column) = python_column_with_fallback(
994                body,
995                evidence(add_line(class.line, offset)),
996                Some(attribute.trim()),
997            ) {
998                if let Some(reference) = call_literal(body, "ForeignKey") {
999                    let (foreign_table, foreign_column) = split_reference(&reference);
1000                    table.foreign_keys.push(DatabaseForeignKey {
1001                        name: None,
1002                        columns: vec![column.name.clone()],
1003                        referenced_table: foreign_table,
1004                        referenced_columns: foreign_column.into_iter().collect(),
1005                        evidence: column.evidence,
1006                    });
1007                }
1008                table.columns.push(column);
1009            }
1010        }
1011        document.owners.push(class.name.to_owned());
1012        document.accesses.push(DataAccessObservation {
1013            role: DataAccessRole::ModelBinding,
1014            table: qualified_table_name(&table),
1015            model: None,
1016            owner: Some(class.name.to_owned()),
1017            operation: None,
1018            evidence: table.evidence,
1019        });
1020        document.tables.push(table);
1021    }
1022    document
1023}
1024
1025fn parse_diesel(source_path: &str, input: &str) -> DataDocument {
1026    let mut document = empty_document(source_path, DataArtifactKind::Diesel);
1027    for block in macro_blocks(input, "table!") {
1028        let header = block
1029            .body
1030            .lines()
1031            .find(|line| line.contains('('))
1032            .unwrap_or_default()
1033            .trim();
1034        let name = header.split('(').next().unwrap_or_default().trim();
1035        if name.is_empty() {
1036            mark_incomplete(&mut document, DataWarning::UnsupportedConstruct);
1037            continue;
1038        }
1039        let mut table = table_from_qualified_text(name, evidence(block.line));
1040        let primary_keys = header
1041            .split_once('(')
1042            .and_then(|(_, rest)| rest.split_once(')'))
1043            .map_or_else(Vec::new, |(keys, _)| comma_identifiers(keys));
1044        for (offset, raw_line) in block.body.lines().enumerate() {
1045            let Some((name, data_type)) = raw_line.split_once("->") else {
1046                continue;
1047            };
1048            let name = bounded_identifier(name.trim());
1049            if name.is_empty() {
1050                continue;
1051            }
1052            table.columns.push(DatabaseColumn {
1053                primary_key: primary_keys.contains(&name),
1054                name,
1055                data_type: Some(bounded_identifier(
1056                    data_type.trim().trim_end_matches(',').trim(),
1057                )),
1058                nullable: Some(data_type.contains("Nullable<")),
1059                unique: false,
1060                default_present: false,
1061                evidence: evidence(add_line(block.line, offset)),
1062            });
1063        }
1064        push_declaration_access(&mut document, &table, None);
1065        document.tables.push(table);
1066    }
1067    document
1068}
1069
1070fn append_statement_accesses(
1071    statement: &Statement,
1072    line: DataEvidenceLine,
1073    owner: Option<&str>,
1074    document: &mut DataDocument,
1075    depth: usize,
1076) {
1077    if depth >= MAX_DATA_DEPTH {
1078        mark_incomplete(document, DataWarning::LimitExceeded);
1079        return;
1080    }
1081    let mut observations = BTreeSet::new();
1082    match statement {
1083        Statement::Query(query) => {
1084            let mut tables = BTreeSet::new();
1085            collect_query_tables(query, &mut tables, depth + 1);
1086            for table in tables {
1087                observations.insert((DataAccessRole::Reader, DataOperation::Select, table));
1088            }
1089        }
1090        Statement::Insert(insert) => {
1091            if let TableObject::TableName(name) = &insert.table {
1092                observations.insert((
1093                    DataAccessRole::Writer,
1094                    DataOperation::Insert,
1095                    object_name(name),
1096                ));
1097            }
1098            if let Some(query) = insert.source.as_deref() {
1099                let mut tables = BTreeSet::new();
1100                collect_query_tables(query, &mut tables, depth + 1);
1101                for table in tables {
1102                    observations.insert((DataAccessRole::Reader, DataOperation::Select, table));
1103                }
1104            }
1105        }
1106        Statement::Update(update) => {
1107            let mut tables = BTreeSet::new();
1108            collect_table_with_joins(&update.table, &mut tables, depth + 1);
1109            if let Some(table) = tables.into_iter().next() {
1110                observations.insert((DataAccessRole::Writer, DataOperation::Update, table));
1111            }
1112        }
1113        Statement::Delete(delete) => {
1114            let tables = match &delete.from {
1115                FromTable::WithFromKeyword(tables) | FromTable::WithoutKeyword(tables) => tables,
1116            };
1117            let mut names = BTreeSet::new();
1118            for table in tables {
1119                collect_table_with_joins(table, &mut names, depth + 1);
1120            }
1121            for table in names {
1122                observations.insert((DataAccessRole::Writer, DataOperation::Delete, table));
1123            }
1124        }
1125        _ => mark_incomplete(document, DataWarning::UnsupportedConstruct),
1126    }
1127    document
1128        .accesses
1129        .extend(
1130            observations
1131                .into_iter()
1132                .map(|(role, operation, table)| DataAccessObservation {
1133                    role,
1134                    table,
1135                    model: None,
1136                    owner: owner.map(ToOwned::to_owned),
1137                    operation: Some(operation),
1138                    evidence: line,
1139                }),
1140        );
1141}
1142
1143fn collect_query_tables(query: &Query, output: &mut BTreeSet<String>, depth: usize) {
1144    if depth >= MAX_DATA_DEPTH {
1145        return;
1146    }
1147    if let Some(with) = &query.with {
1148        for cte in &with.cte_tables {
1149            collect_query_tables(&cte.query, output, depth + 1);
1150        }
1151    }
1152    collect_set_expr_tables(&query.body, output, depth + 1);
1153}
1154
1155fn collect_set_expr_tables(expression: &SetExpr, output: &mut BTreeSet<String>, depth: usize) {
1156    if depth >= MAX_DATA_DEPTH {
1157        return;
1158    }
1159    match expression {
1160        SetExpr::Select(select) => {
1161            for table in &select.from {
1162                collect_table_with_joins(table, output, depth + 1);
1163            }
1164        }
1165        SetExpr::Query(query) => collect_query_tables(query, output, depth + 1),
1166        SetExpr::SetOperation { left, right, .. } => {
1167            collect_set_expr_tables(left, output, depth + 1);
1168            collect_set_expr_tables(right, output, depth + 1);
1169        }
1170        SetExpr::Insert(statement) | SetExpr::Update(statement) | SetExpr::Delete(statement) => {
1171            let mut ignored = empty_document("", DataArtifactKind::LiteralQuerySource);
1172            append_statement_accesses(statement, evidence(1), None, &mut ignored, depth + 1);
1173            output.extend(ignored.accesses.into_iter().map(|access| access.table));
1174        }
1175        SetExpr::Table(table) => {
1176            if let Some(table_name) = table.table_name.as_deref() {
1177                output.insert(bounded_identifier(table_name));
1178            }
1179        }
1180        SetExpr::Values(_) | SetExpr::Merge(_) => {}
1181    }
1182}
1183
1184fn collect_table_with_joins(table: &TableWithJoins, output: &mut BTreeSet<String>, depth: usize) {
1185    collect_table_factor(&table.relation, output, depth + 1);
1186    for join in &table.joins {
1187        collect_table_factor(&join.relation, output, depth + 1);
1188    }
1189}
1190
1191fn collect_table_factor(factor: &TableFactor, output: &mut BTreeSet<String>, depth: usize) {
1192    if depth >= MAX_DATA_DEPTH {
1193        return;
1194    }
1195    match factor {
1196        TableFactor::Table { name, args, .. } if args.is_none() => {
1197            output.insert(object_name(name));
1198        }
1199        TableFactor::Derived { subquery, .. } => collect_query_tables(subquery, output, depth + 1),
1200        _ => {}
1201    }
1202}
1203
1204fn table_from_name(name: &ObjectName, line: DataEvidenceLine) -> DatabaseTable {
1205    table_from_qualified_text(&object_name(name), line)
1206}
1207
1208fn table_from_qualified_text(name: &str, line: DataEvidenceLine) -> DatabaseTable {
1209    let parts = name
1210        .split('.')
1211        .map(clean_identifier)
1212        .filter(|part| !part.is_empty())
1213        .collect::<Vec<_>>();
1214    let table_name = parts.last().cloned().unwrap_or_default();
1215    let schema = (parts.len() >= 2).then(|| parts[parts.len() - 2].clone());
1216    let database = (parts.len() >= 3).then(|| parts[parts.len() - 3].clone());
1217    DatabaseTable {
1218        database,
1219        schema,
1220        name: table_name,
1221        columns: Vec::new(),
1222        indexes: Vec::new(),
1223        foreign_keys: Vec::new(),
1224        evidence: line,
1225    }
1226}
1227
1228fn ensure_table<'a>(
1229    document: &'a mut DataDocument,
1230    name: &ObjectName,
1231    line: DataEvidenceLine,
1232) -> &'a mut DatabaseTable {
1233    ensure_text_table(document, &object_name(name), line)
1234}
1235
1236fn ensure_text_table<'a>(
1237    document: &'a mut DataDocument,
1238    name: &str,
1239    line: DataEvidenceLine,
1240) -> &'a mut DatabaseTable {
1241    let candidate = table_from_qualified_text(name, line);
1242    let key = table_key(&candidate);
1243    if let Some(index) = document
1244        .tables
1245        .iter()
1246        .position(|table| table_key(table) == key)
1247    {
1248        return &mut document.tables[index];
1249    }
1250    document.tables.push(candidate);
1251    let index = document.tables.len() - 1;
1252    &mut document.tables[index]
1253}
1254
1255fn push_declaration_access(
1256    document: &mut DataDocument,
1257    table: &DatabaseTable,
1258    owner: Option<&str>,
1259) {
1260    document.accesses.push(DataAccessObservation {
1261        role: DataAccessRole::Declaration,
1262        table: qualified_table_name(table),
1263        model: None,
1264        owner: owner.map(ToOwned::to_owned),
1265        operation: None,
1266        evidence: table.evidence,
1267    });
1268}
1269
1270fn mark_columns(table: &mut DatabaseTable, names: &[String], primary: bool, unique: bool) {
1271    for column in &mut table.columns {
1272        if names
1273            .iter()
1274            .any(|name| clean_identifier(name) == column.name)
1275        {
1276            column.primary_key |= primary;
1277            column.unique |= unique;
1278        }
1279    }
1280}
1281
1282fn object_name(name: &ObjectName) -> String {
1283    bounded_identifier(&name.to_string())
1284}
1285
1286fn index_column_name(column: &IndexColumn) -> Option<String> {
1287    match &column.column.expr {
1288        Expr::Identifier(identifier) => Some(bounded_identifier(&identifier.value)),
1289        Expr::CompoundIdentifier(identifiers) => Some(
1290            identifiers
1291                .iter()
1292                .map(|identifier| bounded_identifier(&identifier.value))
1293                .collect::<Vec<_>>()
1294                .join("."),
1295        ),
1296        _ => None,
1297    }
1298}
1299
1300fn safe_data_type(value: &str) -> String {
1301    if value.contains(['\'', '"']) {
1302        value.split_once('(').map_or_else(
1303            || bounded_identifier(value),
1304            |(name, _)| bounded_identifier(name),
1305        )
1306    } else {
1307        bounded_identifier(value)
1308    }
1309}
1310
1311fn qualified_table_name(table: &DatabaseTable) -> String {
1312    [
1313        table.database.as_deref(),
1314        table.schema.as_deref(),
1315        Some(&table.name),
1316    ]
1317    .into_iter()
1318    .flatten()
1319    .collect::<Vec<_>>()
1320    .join(".")
1321}
1322
1323fn table_key(table: &DatabaseTable) -> (Option<&str>, Option<&str>, &str) {
1324    (
1325        table.database.as_deref(),
1326        table.schema.as_deref(),
1327        &table.name,
1328    )
1329}
1330
1331fn finish_document(document: &mut DataDocument) {
1332    let mut tables = BTreeMap::<(Option<String>, Option<String>, String), DatabaseTable>::new();
1333    for table in std::mem::take(&mut document.tables) {
1334        let key = (
1335            table.database.clone(),
1336            table.schema.clone(),
1337            table.name.clone(),
1338        );
1339        if let Some(existing) = tables.get_mut(&key) {
1340            existing.columns.extend(table.columns);
1341            existing.indexes.extend(table.indexes);
1342            existing.foreign_keys.extend(table.foreign_keys);
1343            existing.evidence = existing.evidence.min(table.evidence);
1344        } else {
1345            tables.insert(key, table);
1346        }
1347    }
1348    document.tables = tables.into_values().collect();
1349    for table in &mut document.tables {
1350        table.columns.sort();
1351        table.columns.dedup();
1352        table.indexes.sort();
1353        table.indexes.dedup();
1354        table.foreign_keys.sort();
1355        table.foreign_keys.dedup();
1356    }
1357    document.accesses.sort();
1358    document.accesses.dedup();
1359    document.frameworks.sort();
1360    document.frameworks.dedup();
1361    document.references.sort();
1362    document.references.dedup();
1363    document.owners.sort();
1364    document.owners.dedup();
1365    document.warnings.sort();
1366    document.warnings.dedup();
1367    document.incomplete |= !document.warnings.is_empty();
1368
1369    if structured_item_count(document) > MAX_DATA_ITEMS {
1370        truncate_structured_items(document);
1371        mark_incomplete(document, DataWarning::LimitExceeded);
1372        document.warnings.sort();
1373        document.warnings.dedup();
1374    }
1375
1376    let databases = document
1377        .tables
1378        .iter()
1379        .filter_map(|table| table.database.clone())
1380        .collect::<BTreeSet<_>>();
1381    let schemas = document
1382        .tables
1383        .iter()
1384        .filter_map(|table| table.schema.clone())
1385        .collect::<BTreeSet<_>>();
1386    document.database_name = single_value(databases);
1387    document.schema_name = single_value(schemas);
1388}
1389
1390fn structured_item_count(document: &DataDocument) -> usize {
1391    document.tables.len()
1392        + document.accesses.len()
1393        + document.frameworks.len()
1394        + document.references.len()
1395        + document.owners.len()
1396        + document
1397            .tables
1398            .iter()
1399            .map(|table| table.columns.len() + table.indexes.len() + table.foreign_keys.len())
1400            .sum::<usize>()
1401}
1402
1403fn truncate_structured_items(document: &mut DataDocument) {
1404    let mut remaining = MAX_DATA_ITEMS;
1405
1406    document.tables.truncate(remaining);
1407    remaining = remaining.saturating_sub(document.tables.len());
1408    document.accesses.truncate(remaining);
1409    remaining = remaining.saturating_sub(document.accesses.len());
1410    document.frameworks.truncate(remaining);
1411    remaining = remaining.saturating_sub(document.frameworks.len());
1412    document.references.truncate(remaining);
1413    remaining = remaining.saturating_sub(document.references.len());
1414    document.owners.truncate(remaining);
1415    remaining = remaining.saturating_sub(document.owners.len());
1416
1417    for table in &mut document.tables {
1418        table.columns.truncate(remaining);
1419        remaining = remaining.saturating_sub(table.columns.len());
1420        table.indexes.truncate(remaining);
1421        remaining = remaining.saturating_sub(table.indexes.len());
1422        table.foreign_keys.truncate(remaining);
1423        remaining = remaining.saturating_sub(table.foreign_keys.len());
1424    }
1425}
1426
1427fn single_value(values: BTreeSet<String>) -> Option<String> {
1428    (values.len() == 1)
1429        .then(|| values.into_iter().next())
1430        .flatten()
1431}
1432
1433fn mark_incomplete(document: &mut DataDocument, warning: DataWarning) {
1434    document.incomplete = true;
1435    document.warnings.push(warning);
1436}
1437
1438fn evidence(line: u32) -> DataEvidenceLine {
1439    DataEvidenceLine(line.clamp(1, MAX_DATA_SOURCE_LINES))
1440}
1441
1442fn add_evidence(base: u32, relative: u32) -> DataEvidenceLine {
1443    evidence(base.saturating_add(relative.saturating_sub(1)))
1444}
1445
1446fn add_line(base: u32, offset: usize) -> u32 {
1447    base.saturating_add(u32::try_from(offset).unwrap_or(u32::MAX))
1448        .min(MAX_DATA_SOURCE_LINES)
1449}
1450
1451fn bounded_identifier(value: &str) -> String {
1452    value.trim().chars().take(MAX_IDENTIFIER_CHARS).collect()
1453}
1454
1455fn clean_identifier(value: &str) -> String {
1456    bounded_identifier(
1457        value.trim_matches(|character| matches!(character, '"' | '\'' | '`' | '[' | ']')),
1458    )
1459}
1460
1461fn filename_order_hint(source_path: &str) -> Option<u64> {
1462    Path::new(source_path)
1463        .file_name()
1464        .and_then(|name| name.to_str())
1465        .and_then(|name| {
1466            let digits = name
1467                .chars()
1468                .take_while(char::is_ascii_digit)
1469                .collect::<String>();
1470            (!digits.is_empty()).then_some(digits)
1471        })
1472        .and_then(|digits| digits.parse().ok())
1473}
1474
1475fn sql_migration_path(normalized_path: &str, filename: &str) -> bool {
1476    normalized_path
1477        .split('/')
1478        .any(|component| component == "migrations")
1479        || filename.ends_with(".up.sql")
1480        || filename.ends_with(".down.sql")
1481}
1482
1483fn sql_contains_only_queries(input: &str) -> bool {
1484    Parser::parse_sql(&GenericDialect {}, input).is_ok_and(|statements| {
1485        !statements.is_empty()
1486            && statements.iter().all(|statement| {
1487                matches!(
1488                    statement,
1489                    Statement::Query(_)
1490                        | Statement::Insert(_)
1491                        | Statement::Update(_)
1492                        | Statement::Delete(_)
1493                )
1494            })
1495    })
1496}
1497
1498fn sql_migration_metadata(source_path: &str, input: &str) -> MigrationMetadata {
1499    let filename = Path::new(source_path)
1500        .file_name()
1501        .and_then(|name| name.to_str())
1502        .unwrap_or_default();
1503    let order_hint = filename_order_hint(source_path);
1504    let revision = order_hint.map(|value| value.to_string());
1505    MigrationMetadata {
1506        revision,
1507        down_revision: None,
1508        order_hint,
1509        reversible: filename.ends_with(".up.sql")
1510            || filename.ends_with(".down.sql")
1511            || contains_keyword(input, "ROLLBACK"),
1512        evidence: evidence(1),
1513    }
1514}
1515
1516fn inferred_crate_root(source_path: &str) -> String {
1517    let normalized = source_path.replace('\\', "/");
1518    for marker in ["/src/", "/tests/", "/examples/", "/benches/"] {
1519        if let Some((root, _)) = normalized.split_once(marker) {
1520            return root.to_owned();
1521        }
1522    }
1523    if normalized.starts_with("src/")
1524        || normalized.starts_with("tests/")
1525        || normalized.starts_with("examples/")
1526        || normalized.starts_with("benches/")
1527    {
1528        return String::new();
1529    }
1530    normalized
1531        .rsplit_once('/')
1532        .map_or_else(String::new, |(parent, _)| parent.to_owned())
1533}
1534
1535fn normalize_data_reference(crate_root: &str, reference: &str) -> Option<String> {
1536    let reference = reference.replace('\\', "/");
1537    if reference.is_empty()
1538        || reference.starts_with('/')
1539        || reference.as_bytes().get(1) == Some(&b':')
1540    {
1541        return None;
1542    }
1543    let mut components = crate_root
1544        .replace('\\', "/")
1545        .split('/')
1546        .filter(|component| !component.is_empty() && *component != ".")
1547        .map(str::to_owned)
1548        .collect::<Vec<_>>();
1549    for component in reference.split('/') {
1550        match component {
1551            "" | "." => {}
1552            ".." if !components.is_empty() => {
1553                components.pop();
1554            }
1555            ".." => return None,
1556            value => components.push(value.to_owned()),
1557        }
1558    }
1559    (!components.is_empty()).then(|| components.join("/"))
1560}
1561
1562fn sql_statement_lines(input: &str) -> Vec<u32> {
1563    let mut lines = Vec::new();
1564    let mut line = 1_u32;
1565    let mut quote = None;
1566    let mut escaped = false;
1567    let mut statement_started = false;
1568    let mut line_comment = false;
1569    let mut block_comment = false;
1570    let mut characters = input.chars().peekable();
1571    while let Some(character) = characters.next() {
1572        if character == '\n' {
1573            line = line.saturating_add(1);
1574            line_comment = false;
1575            continue;
1576        }
1577        if line_comment {
1578            continue;
1579        }
1580        if block_comment {
1581            if character == '*' && characters.peek() == Some(&'/') {
1582                characters.next();
1583                block_comment = false;
1584            }
1585            continue;
1586        }
1587        if escaped {
1588            escaped = false;
1589            continue;
1590        }
1591        if character == '\\' && quote.is_some() {
1592            escaped = true;
1593            continue;
1594        }
1595        if let Some(active) = quote {
1596            if character == active {
1597                quote = None;
1598            }
1599            continue;
1600        }
1601        if character == '-' && characters.peek() == Some(&'-') {
1602            characters.next();
1603            line_comment = true;
1604            continue;
1605        }
1606        if character == '/' && characters.peek() == Some(&'*') {
1607            characters.next();
1608            block_comment = true;
1609            continue;
1610        }
1611        if !statement_started && !character.is_whitespace() && character != ';' {
1612            lines.push(line);
1613            statement_started = true;
1614        }
1615        if matches!(character, '\'' | '"' | '`') {
1616            quote = Some(character);
1617        } else if character == ';' {
1618            statement_started = false;
1619        }
1620    }
1621    lines
1622}
1623
1624#[derive(Debug, Clone, Copy)]
1625struct SqlStatementChunk<'a> {
1626    text: &'a str,
1627    line: u32,
1628}
1629
1630fn sql_statement_chunks(input: &str) -> Vec<SqlStatementChunk<'_>> {
1631    let mut output = Vec::new();
1632    let mut start = 0_usize;
1633    let mut line = 1_u32;
1634    let mut statement_line = None;
1635    let mut quote = None;
1636    let mut escaped = false;
1637    let mut line_comment = false;
1638    let mut block_comment = false;
1639    let mut characters = input.char_indices().peekable();
1640    while let Some((index, character)) = characters.next() {
1641        if character == '\n' {
1642            line = line.saturating_add(1);
1643            line_comment = false;
1644            continue;
1645        }
1646        if line_comment {
1647            continue;
1648        }
1649        if block_comment {
1650            if character == '*' && characters.peek().is_some_and(|(_, next)| *next == '/') {
1651                characters.next();
1652                block_comment = false;
1653            }
1654            continue;
1655        }
1656        if escaped {
1657            escaped = false;
1658            continue;
1659        }
1660        if character == '\\' && quote.is_some() {
1661            escaped = true;
1662            continue;
1663        }
1664        if let Some(active) = quote {
1665            if character == active {
1666                quote = None;
1667            }
1668            continue;
1669        }
1670        if character == '-' && characters.peek().is_some_and(|(_, next)| *next == '-') {
1671            characters.next();
1672            line_comment = true;
1673            continue;
1674        }
1675        if character == '#' {
1676            line_comment = true;
1677            continue;
1678        }
1679        if character == '/' && characters.peek().is_some_and(|(_, next)| *next == '*') {
1680            characters.next();
1681            block_comment = true;
1682            continue;
1683        }
1684        if !character.is_whitespace() && character != ';' && statement_line.is_none() {
1685            statement_line = Some(line);
1686        }
1687        if matches!(character, '\'' | '"' | '`') {
1688            quote = Some(character);
1689        } else if character == ';' {
1690            let end = index.saturating_add(character.len_utf8());
1691            if let Some(statement_line) = statement_line.take() {
1692                output.push(SqlStatementChunk {
1693                    text: &input[start..end],
1694                    line: statement_line,
1695                });
1696            }
1697            start = end;
1698        }
1699        if output.len() >= MAX_DATA_ITEMS {
1700            break;
1701        }
1702    }
1703    if output.len() < MAX_DATA_ITEMS
1704        && let Some(statement_line) = statement_line
1705        && start < input.len()
1706    {
1707        output.push(SqlStatementChunk {
1708            text: &input[start..],
1709            line: statement_line,
1710        });
1711    }
1712    output
1713}
1714
1715fn contains_keyword(input: &str, keyword: &str) -> bool {
1716    input
1717        .split(|character: char| !character.is_ascii_alphanumeric() && character != '_')
1718        .any(|word| word.eq_ignore_ascii_case(keyword))
1719}
1720
1721#[derive(Debug)]
1722struct NamedBlock<'a> {
1723    name: &'a str,
1724    body: &'a str,
1725    line: u32,
1726}
1727
1728fn named_blocks<'a>(input: &'a str, keyword: &str) -> Vec<NamedBlock<'a>> {
1729    let mut output = Vec::new();
1730    let mut offset = 0;
1731    let marker = format!("{keyword} ");
1732    while let Some(relative) = input[offset..].find(&marker) {
1733        let start = offset + relative;
1734        if start > 0
1735            && input[..start]
1736                .chars()
1737                .next_back()
1738                .is_some_and(|character| character.is_ascii_alphanumeric() || character == '_')
1739        {
1740            offset = start + marker.len();
1741            continue;
1742        }
1743        let name_start = start + marker.len();
1744        let name_end = input[name_start..]
1745            .find(|character: char| character.is_whitespace() || character == '{')
1746            .map_or(input.len(), |relative_end| name_start + relative_end);
1747        let Some(open_relative) = input[name_end..].find('{') else {
1748            break;
1749        };
1750        let open = name_end + open_relative;
1751        let Some(close) = matching_delimiter(input, open, '{', '}') else {
1752            break;
1753        };
1754        output.push(NamedBlock {
1755            name: &input[name_start..name_end],
1756            body: &input[open + 1..close],
1757            line: line_at(input, start),
1758        });
1759        if output.len() >= MAX_DATA_ITEMS {
1760            break;
1761        }
1762        offset = close + 1;
1763    }
1764    output
1765}
1766
1767fn macro_blocks<'a>(input: &'a str, macro_name: &str) -> Vec<NamedBlock<'a>> {
1768    let mut output = Vec::new();
1769    let mut offset = 0;
1770    while let Some(relative) = input[offset..].find(macro_name) {
1771        let start = offset + relative;
1772        let Some(open_relative) = input[start + macro_name.len()..].find('{') else {
1773            break;
1774        };
1775        let open = start + macro_name.len() + open_relative;
1776        let Some(close) = matching_delimiter(input, open, '{', '}') else {
1777            break;
1778        };
1779        output.push(NamedBlock {
1780            name: &input[start..start + macro_name.len()],
1781            body: &input[open + 1..close],
1782            line: line_at(input, start),
1783        });
1784        if output.len() >= MAX_DATA_ITEMS {
1785            break;
1786        }
1787        offset = close + 1;
1788    }
1789    output
1790}
1791
1792fn matching_delimiter(input: &str, open: usize, left: char, right: char) -> Option<usize> {
1793    let mut depth = 0_usize;
1794    let mut quote = None;
1795    let mut escaped = false;
1796    for (relative, character) in input[open..].char_indices() {
1797        if escaped {
1798            escaped = false;
1799            continue;
1800        }
1801        if character == '\\' && quote.is_some() {
1802            escaped = true;
1803            continue;
1804        }
1805        if let Some(active) = quote {
1806            if character == active {
1807                quote = None;
1808            }
1809            continue;
1810        }
1811        if matches!(character, '\'' | '"' | '`') {
1812            quote = Some(character);
1813        } else if character == left {
1814            depth += 1;
1815            if depth > MAX_DATA_DEPTH {
1816                return None;
1817            }
1818        } else if character == right {
1819            depth = depth.saturating_sub(1);
1820            if depth == 0 {
1821                return Some(open + relative);
1822            }
1823        }
1824    }
1825    None
1826}
1827
1828fn line_at(input: &str, byte: usize) -> u32 {
1829    u32::try_from(
1830        input[..byte.min(input.len())]
1831            .bytes()
1832            .filter(|byte| *byte == b'\n')
1833            .count()
1834            + 1,
1835    )
1836    .unwrap_or(MAX_DATA_SOURCE_LINES)
1837    .min(MAX_DATA_SOURCE_LINES)
1838}
1839
1840fn find_call_literal(input: &str, marker: &str) -> Option<String> {
1841    let position = input.find(marker)?;
1842    first_quoted(&input[position + marker.len()..])
1843}
1844
1845fn bracket_values_after(input: &str, marker: &str) -> Option<Vec<String>> {
1846    let position = input.find(marker)?;
1847    bracket_values(&input[position + marker.len()..])
1848}
1849
1850fn bracket_values(input: &str) -> Option<Vec<String>> {
1851    let open = input.find('[')?;
1852    let close = matching_delimiter(input, open, '[', ']')?;
1853    Some(comma_identifiers(&input[open + 1..close]))
1854}
1855
1856fn comma_identifiers(input: &str) -> Vec<String> {
1857    input
1858        .split(',')
1859        .map(clean_identifier)
1860        .filter(|value| !value.is_empty())
1861        .collect()
1862}
1863
1864#[derive(Debug)]
1865struct Call {
1866    body: String,
1867    line: u32,
1868}
1869
1870fn collect_calls(input: &str, marker: &str) -> Vec<Call> {
1871    let mut output = Vec::new();
1872    let mut offset = 0;
1873    while let Some(relative) = input[offset..].find(marker) {
1874        let start = offset + relative;
1875        let body_start = start + marker.len();
1876        let Some(body) = balanced_slice(&input[body_start..]) else {
1877            break;
1878        };
1879        output.push(Call {
1880            body: body.to_owned(),
1881            line: line_at(input, start),
1882        });
1883        offset = body_start + body.len() + 1;
1884        if output.len() >= MAX_DATA_ITEMS {
1885            break;
1886        }
1887    }
1888    output
1889}
1890
1891fn balanced_slice(input: &str) -> Option<&str> {
1892    let mut depth = 1_usize;
1893    let mut quote = None;
1894    let mut escaped = false;
1895    for (index, character) in input.char_indices() {
1896        if escaped {
1897            escaped = false;
1898            continue;
1899        }
1900        if character == '\\' && quote.is_some() {
1901            escaped = true;
1902            continue;
1903        }
1904        if let Some(active) = quote {
1905            if character == active {
1906                quote = None;
1907            }
1908            continue;
1909        }
1910        if matches!(character, '\'' | '"' | '`') {
1911            quote = Some(character);
1912        } else if character == '(' {
1913            depth += 1;
1914            if depth > MAX_DATA_DEPTH {
1915                return None;
1916            }
1917        } else if character == ')' {
1918            depth -= 1;
1919            if depth == 0 {
1920                return Some(&input[..index]);
1921            }
1922        }
1923    }
1924    None
1925}
1926
1927fn first_quoted(input: &str) -> Option<String> {
1928    quoted_values(input).into_iter().next()
1929}
1930
1931fn quoted_values(input: &str) -> Vec<String> {
1932    let mut output = Vec::new();
1933    let bytes = input.as_bytes();
1934    let mut index = 0;
1935    while index < bytes.len() {
1936        if !matches!(bytes[index], b'\'' | b'"') {
1937            index += 1;
1938            continue;
1939        }
1940        let quote = bytes[index];
1941        let start = index + 1;
1942        index = start;
1943        let mut escaped = false;
1944        while index < bytes.len() {
1945            if escaped {
1946                escaped = false;
1947            } else if bytes[index] == b'\\' {
1948                escaped = true;
1949            } else if bytes[index] == quote {
1950                output.push(bounded_identifier(&input[start..index]));
1951                index += 1;
1952                break;
1953            }
1954            index += 1;
1955        }
1956    }
1957    output
1958}
1959
1960fn assignment_literal(input: &str, name: &str) -> Option<String> {
1961    input.lines().find_map(|line| {
1962        let (left, right) = line.split_once('=')?;
1963        (left.trim() == name).then(|| first_quoted(right)).flatten()
1964    })
1965}
1966
1967fn assignment_line(input: &str, name: &str) -> Option<u32> {
1968    input.lines().enumerate().find_map(|(index, line)| {
1969        let (left, _) = line.split_once('=')?;
1970        (left.trim() == name).then(|| u32::try_from(index + 1).unwrap_or(MAX_DATA_SOURCE_LINES))
1971    })
1972}
1973
1974fn python_column(input: &str, line: DataEvidenceLine) -> Option<DatabaseColumn> {
1975    python_column_with_fallback(input, line, None)
1976}
1977
1978fn python_column_with_fallback(
1979    input: &str,
1980    line: DataEvidenceLine,
1981    fallback_name: Option<&str>,
1982) -> Option<DatabaseColumn> {
1983    let values = quoted_values(input);
1984    let explicit_name = input.trim_start().starts_with(['\'', '"']);
1985    let name = if explicit_name {
1986        values.first().cloned()
1987    } else {
1988        fallback_name.map(bounded_identifier)
1989    }?;
1990    let type_position = usize::from(explicit_name);
1991    let data_type = input
1992        .split(',')
1993        .nth(type_position)
1994        .map(str::trim)
1995        .filter(|value| !value.contains("ForeignKey"))
1996        .map(|value| {
1997            bounded_identifier(
1998                value
1999                    .trim_start_matches("sa.")
2000                    .trim_end_matches("()")
2001                    .trim(),
2002            )
2003        });
2004    Some(DatabaseColumn {
2005        name,
2006        data_type,
2007        nullable: if input.contains("nullable=False") {
2008            Some(false)
2009        } else if input.contains("nullable=True") {
2010            Some(true)
2011        } else {
2012            None
2013        },
2014        primary_key: input.contains("primary_key=True"),
2015        unique: input.contains("unique=True"),
2016        default_present: input.contains("default=") || input.contains("server_default="),
2017        evidence: line,
2018    })
2019}
2020
2021fn python_foreign_key(input: &str, line: DataEvidenceLine) -> Option<DatabaseForeignKey> {
2022    let lists = all_bracket_values(input);
2023    let local = lists.first()?.clone();
2024    let references = lists.get(1)?.clone();
2025    let reference = references.first()?;
2026    let (table, column) = split_reference(reference);
2027    Some(DatabaseForeignKey {
2028        name: None,
2029        columns: local,
2030        referenced_table: table,
2031        referenced_columns: column.into_iter().collect(),
2032        evidence: line,
2033    })
2034}
2035
2036fn all_bracket_values(input: &str) -> Vec<Vec<String>> {
2037    let mut output = Vec::new();
2038    let mut offset = 0;
2039    while let Some(relative) = input[offset..].find('[') {
2040        let open = offset + relative;
2041        let Some(close) = matching_delimiter(input, open, '[', ']') else {
2042            break;
2043        };
2044        output.push(
2045            quoted_values(&input[open + 1..close])
2046                .into_iter()
2047                .map(|value| bounded_identifier(&value))
2048                .collect(),
2049        );
2050        offset = close + 1;
2051    }
2052    output
2053}
2054
2055fn split_reference(reference: &str) -> (String, Option<String>) {
2056    reference.rsplit_once('.').map_or_else(
2057        || (bounded_identifier(reference), None),
2058        |(table, column)| (bounded_identifier(table), Some(bounded_identifier(column))),
2059    )
2060}
2061
2062fn table_argument_literal(input: &str, name: &str) -> Option<String> {
2063    input.lines().find_map(|line| {
2064        if !line.contains("__table_args__") || !line.contains(name) {
2065            return None;
2066        }
2067        let position = line.find(name)?;
2068        let remainder = &line[position + name.len()..];
2069        let value_start = remainder.find(':').map_or(0, |colon| colon + 1);
2070        first_quoted(&remainder[value_start..])
2071    })
2072}
2073
2074#[derive(Debug)]
2075struct PythonClass<'a> {
2076    name: &'a str,
2077    body: &'a str,
2078    line: u32,
2079}
2080
2081fn python_classes(input: &str) -> Vec<PythonClass<'_>> {
2082    let mut starts = Vec::new();
2083    let mut byte = 0;
2084    for (index, line) in input.lines().enumerate() {
2085        if let Some(rest) = line.strip_prefix("class ")
2086            && let Some(end) = rest.find(['(', ':'])
2087        {
2088            starts.push((byte, index + 1, &rest[..end]));
2089            if starts.len() >= MAX_DATA_ITEMS {
2090                break;
2091            }
2092        }
2093        byte += line.len() + 1;
2094    }
2095    starts
2096        .iter()
2097        .enumerate()
2098        .map(|(index, (start, line, name))| {
2099            let body_start = input[*start..]
2100                .find('\n')
2101                .map_or(input.len(), |relative| start + relative + 1);
2102            let body_end = starts
2103                .get(index + 1)
2104                .map_or(input.len(), |(next, _, _)| *next);
2105            PythonClass {
2106                name,
2107                body: &input[body_start..body_end],
2108                line: u32::try_from(*line).unwrap_or(MAX_DATA_SOURCE_LINES),
2109            }
2110        })
2111        .collect()
2112}
2113
2114fn call_literal(input: &str, call: &str) -> Option<String> {
2115    let position = input.find(call)?;
2116    first_quoted(&input[position + call.len()..])
2117}
2118
2119#[derive(Debug, Clone)]
2120struct SourceLiteral {
2121    value: String,
2122    line: u32,
2123    start: usize,
2124    end: usize,
2125    dynamic: bool,
2126    interpolated: bool,
2127}
2128
2129fn quoted_literals(input: &str, language: SourceLanguage) -> Vec<SourceLiteral> {
2130    if language == SourceLanguage::Rust {
2131        return rust_source_string_literals(input);
2132    }
2133    let bytes = input.as_bytes();
2134    let mut output = Vec::new();
2135    let mut index = 0;
2136    while index < bytes.len() {
2137        let quote = match bytes[index] {
2138            b'\'' | b'"' | b'`' => bytes[index],
2139            _ => {
2140                index += 1;
2141                continue;
2142            }
2143        };
2144        let triple = language == SourceLanguage::Python
2145            && index + 2 < bytes.len()
2146            && bytes[index + 1] == quote
2147            && bytes[index + 2] == quote;
2148        let delimiter = if triple { 3 } else { 1 };
2149        let content_start = index + delimiter;
2150        let mut cursor = content_start;
2151        let mut escaped = false;
2152        let mut closed = None;
2153        while cursor < bytes.len() {
2154            if triple
2155                && cursor + 2 < bytes.len()
2156                && bytes[cursor] == quote
2157                && bytes[cursor + 1] == quote
2158                && bytes[cursor + 2] == quote
2159            {
2160                closed = Some(cursor);
2161                break;
2162            }
2163            if !triple {
2164                if escaped {
2165                    escaped = false;
2166                    cursor += 1;
2167                    continue;
2168                }
2169                if bytes[cursor] == b'\\' {
2170                    escaped = true;
2171                    cursor += 1;
2172                    continue;
2173                }
2174                if bytes[cursor] == quote {
2175                    closed = Some(cursor);
2176                    break;
2177                }
2178            }
2179            cursor += 1;
2180        }
2181        let Some(content_end) = closed else {
2182            break;
2183        };
2184        let value = input[content_start..content_end].to_owned();
2185        let end = content_end + delimiter;
2186        let interpolated = is_interpolated(input, language, index, quote, &value);
2187        let dynamic = interpolated || adjacent_dynamic_operator(input, index, end);
2188        output.push(SourceLiteral {
2189            value,
2190            line: line_at(input, index),
2191            start: index,
2192            end,
2193            dynamic,
2194            interpolated,
2195        });
2196        index = end;
2197        if output.len() >= MAX_DATA_ITEMS {
2198            break;
2199        }
2200    }
2201    output
2202}
2203
2204fn rust_source_string_literals(input: &str) -> Vec<SourceLiteral> {
2205    let mut parser = tree_sitter::Parser::new();
2206    let grammar = tree_sitter_rust::LANGUAGE.into();
2207    if parser.set_language(&grammar).is_err() {
2208        return Vec::new();
2209    }
2210    let Some(tree) = parser.parse(input, None) else {
2211        return Vec::new();
2212    };
2213    let mut output = Vec::new();
2214    collect_rust_string_literals(tree.root_node(), input, &mut output);
2215    output.sort_by_key(|literal| literal.start);
2216    output.truncate(MAX_DATA_ITEMS);
2217    output
2218}
2219
2220fn collect_rust_string_literals(
2221    node: SyntaxNode<'_>,
2222    input: &str,
2223    output: &mut Vec<SourceLiteral>,
2224) {
2225    if matches!(
2226        node.kind(),
2227        "string_literal" | "raw_string_literal" | "byte_string_literal" | "raw_byte_string_literal"
2228    ) {
2229        if let Ok(source) = node.utf8_text(input.as_bytes())
2230            && let Some(mut literal) = rust_string_literals(source, 1).into_iter().next()
2231        {
2232            literal.start = literal.start.saturating_add(node.start_byte());
2233            literal.end = literal.end.saturating_add(node.start_byte());
2234            literal.line = u32::try_from(node.start_position().row)
2235                .unwrap_or(MAX_DATA_SOURCE_LINES)
2236                .saturating_add(1);
2237            literal.dynamic = adjacent_dynamic_operator(input, literal.start, literal.end);
2238            output.push(literal);
2239        }
2240        return;
2241    }
2242    let mut cursor = node.walk();
2243    for child in node.children(&mut cursor) {
2244        collect_rust_string_literals(child, input, output);
2245    }
2246}
2247
2248fn rust_string_literals(input: &str, base_line: u32) -> Vec<SourceLiteral> {
2249    let bytes = input.as_bytes();
2250    let mut output = Vec::new();
2251    let mut index = 0;
2252    while index < bytes.len() && output.len() < MAX_DATA_ITEMS {
2253        if bytes[index] == b'r' {
2254            let mut quote = index + 1;
2255            while quote < bytes.len() && bytes[quote] == b'#' {
2256                quote += 1;
2257            }
2258            if quote < bytes.len() && bytes[quote] == b'"' {
2259                let hashes = quote.saturating_sub(index + 1);
2260                let content_start = quote + 1;
2261                let mut cursor = content_start;
2262                while cursor < bytes.len() {
2263                    if bytes[cursor] == b'"'
2264                        && cursor + hashes < bytes.len()
2265                        && (hashes == 0
2266                            || bytes[cursor + 1..=cursor + hashes]
2267                                .iter()
2268                                .all(|byte| *byte == b'#'))
2269                    {
2270                        let end = cursor + hashes + 1;
2271                        output.push(SourceLiteral {
2272                            value: input[content_start..cursor].to_owned(),
2273                            line: base_line.saturating_add(line_at(input, index).saturating_sub(1)),
2274                            start: index,
2275                            end,
2276                            dynamic: false,
2277                            interpolated: false,
2278                        });
2279                        index = end;
2280                        break;
2281                    }
2282                    cursor += 1;
2283                }
2284                if index >= content_start {
2285                    continue;
2286                }
2287            }
2288        }
2289        if bytes[index] != b'"' {
2290            index += 1;
2291            continue;
2292        }
2293        let content_start = index + 1;
2294        let mut cursor = content_start;
2295        let mut escaped = false;
2296        while cursor < bytes.len() {
2297            if escaped {
2298                escaped = false;
2299                cursor += 1;
2300                continue;
2301            }
2302            match bytes[cursor] {
2303                b'\\' => escaped = true,
2304                b'"' => {
2305                    let end = cursor + 1;
2306                    output.push(SourceLiteral {
2307                        value: unescape_rust_string(&input[content_start..cursor]),
2308                        line: base_line.saturating_add(line_at(input, index).saturating_sub(1)),
2309                        start: index,
2310                        end,
2311                        dynamic: adjacent_dynamic_operator(input, index, end),
2312                        interpolated: false,
2313                    });
2314                    index = end;
2315                    break;
2316                }
2317                _ => {}
2318            }
2319            cursor += 1;
2320        }
2321        if index < content_start {
2322            break;
2323        }
2324    }
2325    output
2326}
2327
2328fn unescape_rust_string(value: &str) -> String {
2329    let mut output = String::with_capacity(value.len());
2330    let mut characters = value.chars();
2331    while let Some(character) = characters.next() {
2332        if character != '\\' {
2333            output.push(character);
2334            continue;
2335        }
2336        match characters.next() {
2337            Some('n') => output.push('\n'),
2338            Some('r') => output.push('\r'),
2339            Some('t') => output.push('\t'),
2340            Some('\\') | None => output.push('\\'),
2341            Some('"') => output.push('"'),
2342            Some('\'') => output.push('\''),
2343            Some(other) => {
2344                output.push('\\');
2345                output.push(other);
2346            }
2347        }
2348    }
2349    output
2350}
2351
2352#[derive(Debug, Default)]
2353struct SqlxImports {
2354    names: BTreeMap<String, String>,
2355}
2356
2357impl SqlxImports {
2358    fn discover(input: &str) -> Self {
2359        let mut imports = Self::default();
2360        for statement in input.split(';') {
2361            let compact = statement
2362                .chars()
2363                .filter(|character| !character.is_whitespace())
2364                .collect::<String>();
2365            let Some(rest) = compact.strip_prefix("usesqlx::") else {
2366                continue;
2367            };
2368            if let Some(group) = rest
2369                .strip_prefix('{')
2370                .and_then(|value| value.strip_suffix('}'))
2371            {
2372                for item in group.split(',') {
2373                    imports.insert_item(item);
2374                }
2375            } else {
2376                imports.insert_item(rest);
2377            }
2378        }
2379        imports
2380    }
2381
2382    fn insert_item(&mut self, item: &str) {
2383        let item = item.rsplit("::").next().unwrap_or(item);
2384        if sqlx_api_kind(item).is_some() || matches!(item, "Executor" | "QueryBuilder" | "Migrator")
2385        {
2386            self.names.insert(item.to_owned(), item.to_owned());
2387            return;
2388        }
2389        for (index, _) in item.match_indices("as") {
2390            let (canonical, local_with_as) = item.split_at(index);
2391            let local = &local_with_as[2..];
2392            if !local.is_empty()
2393                && (sqlx_api_kind(canonical).is_some()
2394                    || matches!(canonical, "Executor" | "QueryBuilder" | "Migrator"))
2395            {
2396                self.names.insert(local.to_owned(), canonical.to_owned());
2397                return;
2398            }
2399        }
2400    }
2401
2402    fn canonical<'a>(&'a self, local: &'a str) -> Option<&'a str> {
2403        self.names.get(local).map(String::as_str)
2404    }
2405
2406    fn contains(&self, canonical: &str) -> bool {
2407        self.names.values().any(|value| value == canonical)
2408    }
2409}
2410
2411#[derive(Debug, Default)]
2412struct MysqlAsyncImports {
2413    crate_names: BTreeSet<String>,
2414    traits: BTreeMap<String, String>,
2415}
2416
2417impl MysqlAsyncImports {
2418    fn discover(root: SyntaxNode<'_>, input: &str) -> Self {
2419        let mut declarations = Vec::new();
2420        collect_rust_use_declarations(root, input, &mut declarations);
2421        let mut imports = Self {
2422            crate_names: BTreeSet::from(["mysql_async".to_owned()]),
2423            traits: BTreeMap::new(),
2424        };
2425        for declaration in &declarations {
2426            let compact = compact_rust(declaration);
2427            if let Some(alias) = compact
2428                .strip_prefix("usemysql_asyncas")
2429                .and_then(|value| value.strip_suffix(';'))
2430                .filter(|value| is_rust_identifier(value))
2431            {
2432                imports.crate_names.insert(alias.to_owned());
2433            }
2434        }
2435        for declaration in declarations {
2436            imports.insert_declaration(&compact_rust(declaration));
2437        }
2438        imports
2439    }
2440
2441    fn insert_declaration(&mut self, declaration: &str) {
2442        let Some(path) = declaration.strip_prefix("use") else {
2443            return;
2444        };
2445        if !self
2446            .crate_names
2447            .iter()
2448            .any(|name| path.starts_with(&format!("{name}::")))
2449        {
2450            return;
2451        }
2452        if path.contains("prelude::*") {
2453            for name in MYSQL_ASYNC_TRAITS {
2454                self.traits.insert((*name).to_owned(), (*name).to_owned());
2455            }
2456        }
2457        for canonical in MYSQL_ASYNC_TRAITS {
2458            let Some(start) = path.match_indices(canonical).find_map(|(start, _)| {
2459                let before = &path[..start];
2460                let rest = &path[start + canonical.len()..];
2461                let valid_before = before.ends_with([':', '{', ',']);
2462                let valid_after = rest.starts_with("as")
2463                    || rest
2464                        .chars()
2465                        .next()
2466                        .is_some_and(|character| matches!(character, ',' | '}' | ';'));
2467                (valid_before && valid_after).then_some(start)
2468            }) else {
2469                continue;
2470            };
2471            let rest = &path[start + canonical.len()..];
2472            let alias = rest
2473                .strip_prefix("as")
2474                .map(|value| {
2475                    value
2476                        .chars()
2477                        .take_while(|character| {
2478                            character.is_ascii_alphanumeric() || *character == '_'
2479                        })
2480                        .collect::<String>()
2481                })
2482                .filter(|value| is_rust_identifier(value))
2483                .unwrap_or_else(|| (*canonical).to_owned());
2484            self.traits.insert(alias, (*canonical).to_owned());
2485        }
2486    }
2487
2488    fn contains(&self, canonical: &str) -> bool {
2489        self.traits.values().any(|value| value == canonical)
2490    }
2491
2492    fn canonical<'a>(&'a self, local: &'a str) -> Option<&'a str> {
2493        self.traits.get(local).map(String::as_str)
2494    }
2495
2496    fn is_qualified(&self, function: &str) -> bool {
2497        self.crate_names
2498            .iter()
2499            .any(|name| function.contains(&format!("{name}::")))
2500    }
2501}
2502
2503const MYSQL_ASYNC_TRAITS: &[&str] = &["Queryable", "Query", "WithParams", "BatchQuery"];
2504
2505fn collect_rust_use_declarations<'a>(
2506    node: SyntaxNode<'a>,
2507    input: &'a str,
2508    output: &mut Vec<&'a str>,
2509) {
2510    if node.kind() == "use_declaration" {
2511        if let Ok(declaration) = node.utf8_text(input.as_bytes()) {
2512            output.push(declaration);
2513        }
2514        return;
2515    }
2516    let mut cursor = node.walk();
2517    for child in node.children(&mut cursor) {
2518        collect_rust_use_declarations(child, input, output);
2519    }
2520}
2521
2522fn compact_rust(input: &str) -> String {
2523    input
2524        .chars()
2525        .filter(|character| !character.is_whitespace())
2526        .collect()
2527}
2528
2529fn is_rust_identifier(value: &str) -> bool {
2530    let mut characters = value.chars();
2531    characters
2532        .next()
2533        .is_some_and(|character| character == '_' || character.is_ascii_alphabetic())
2534        && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
2535}
2536
2537#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2538enum SqlxApiKind {
2539    Inline,
2540    QueryFile,
2541    MigrationDirectory,
2542    QueryBuilder,
2543}
2544
2545fn sqlx_api_kind(name: &str) -> Option<SqlxApiKind> {
2546    match name {
2547        "query"
2548        | "query_with"
2549        | "query_with_result"
2550        | "query_unchecked"
2551        | "query_as"
2552        | "query_as_with"
2553        | "query_as_with_result"
2554        | "query_as_unchecked"
2555        | "query_scalar"
2556        | "query_scalar_with"
2557        | "query_scalar_with_result"
2558        | "query_scalar_unchecked"
2559        | "raw_sql" => Some(SqlxApiKind::Inline),
2560        "query_file"
2561        | "query_file_unchecked"
2562        | "query_file_as"
2563        | "query_file_as_unchecked"
2564        | "query_file_scalar"
2565        | "query_file_scalar_unchecked" => Some(SqlxApiKind::QueryFile),
2566        "migrate" => Some(SqlxApiKind::MigrationDirectory),
2567        "QueryBuilder" => Some(SqlxApiKind::QueryBuilder),
2568        _ => None,
2569    }
2570}
2571
2572fn extract_rust_database_source(input: &str, crate_root: &str, document: &mut DataDocument) {
2573    let mut parser = tree_sitter::Parser::new();
2574    let grammar = tree_sitter_rust::LANGUAGE.into();
2575    if parser.set_language(&grammar).is_err() {
2576        mark_incomplete(document, DataWarning::UnsupportedConstruct);
2577        return;
2578    }
2579    let Some(tree) = parser.parse(input, None) else {
2580        mark_incomplete(document, DataWarning::UnsupportedConstruct);
2581        return;
2582    };
2583    if tree.root_node().has_error() {
2584        mark_incomplete(document, DataWarning::SqlParseRecovery);
2585    }
2586    let sqlx_imports = SqlxImports::discover(input);
2587    let mysql_async_imports = MysqlAsyncImports::discover(tree.root_node(), input);
2588    visit_rust_data_nodes(
2589        tree.root_node(),
2590        input,
2591        crate_root,
2592        &sqlx_imports,
2593        &mysql_async_imports,
2594        None,
2595        document,
2596    );
2597}
2598
2599fn visit_rust_data_nodes(
2600    node: SyntaxNode<'_>,
2601    input: &str,
2602    crate_root: &str,
2603    sqlx_imports: &SqlxImports,
2604    mysql_async_imports: &MysqlAsyncImports,
2605    owner: Option<&str>,
2606    document: &mut DataDocument,
2607) {
2608    let owned_name = (node.kind() == "function_item")
2609        .then(|| node.child_by_field_name("name"))
2610        .flatten()
2611        .and_then(|name| name.utf8_text(input.as_bytes()).ok())
2612        .map(bounded_identifier);
2613    let owner = owned_name.as_deref().or(owner);
2614
2615    match node.kind() {
2616        "call_expression" => {
2617            if let (Some(function), Some(arguments)) = (
2618                node.child_by_field_name("function"),
2619                node.child_by_field_name("arguments"),
2620            ) && let (Ok(function), Ok(arguments)) = (
2621                function.utf8_text(input.as_bytes()),
2622                arguments.utf8_text(input.as_bytes()),
2623            ) {
2624                inspect_sqlx_call(
2625                    function,
2626                    arguments,
2627                    node.start_position().row,
2628                    crate_root,
2629                    sqlx_imports,
2630                    owner,
2631                    document,
2632                );
2633                inspect_mysql_async_call(
2634                    function,
2635                    arguments,
2636                    node.start_position().row,
2637                    mysql_async_imports,
2638                    owner,
2639                    document,
2640                );
2641            }
2642        }
2643        "macro_invocation" => {
2644            if let Ok(invocation) = node.utf8_text(input.as_bytes()) {
2645                inspect_sqlx_macro(
2646                    invocation,
2647                    node.start_position().row,
2648                    crate_root,
2649                    sqlx_imports,
2650                    owner,
2651                    document,
2652                );
2653            }
2654        }
2655        _ => {}
2656    }
2657
2658    let mut cursor = node.walk();
2659    for child in node.children(&mut cursor) {
2660        visit_rust_data_nodes(
2661            child,
2662            input,
2663            crate_root,
2664            sqlx_imports,
2665            mysql_async_imports,
2666            owner,
2667            document,
2668        );
2669    }
2670}
2671
2672fn inspect_sqlx_call(
2673    function: &str,
2674    arguments: &str,
2675    zero_based_line: usize,
2676    crate_root: &str,
2677    imports: &SqlxImports,
2678    owner: Option<&str>,
2679    document: &mut DataDocument,
2680) {
2681    let compact = strip_rust_generics(function);
2682    let local = compact
2683        .rsplit([':', '.'])
2684        .find(|component| !component.is_empty())
2685        .unwrap_or(&compact);
2686    let qualified = compact.contains("sqlx::");
2687    let canonical = if qualified {
2688        local
2689    } else {
2690        imports.canonical(local).unwrap_or(local)
2691    };
2692    let executor_method = matches!(
2693        canonical,
2694        "execute"
2695            | "execute_many"
2696            | "fetch"
2697            | "fetch_many"
2698            | "fetch_all"
2699            | "fetch_one"
2700            | "fetch_optional"
2701    ) && (imports.contains("Executor")
2702        || compact.contains("sqlx::Executor::"));
2703    let kind = sqlx_api_kind(canonical)
2704        .or_else(|| executor_method.then_some(SqlxApiKind::Inline))
2705        .or_else(|| {
2706            let builder = compact.ends_with("QueryBuilder::new")
2707                || compact.ends_with("QueryBuilder::with_arguments")
2708                || imports.canonical(compact.split("::").next().unwrap_or_default())
2709                    == Some("QueryBuilder");
2710            builder.then_some(SqlxApiKind::QueryBuilder)
2711        });
2712    let migrator = compact.ends_with("Migrator::new")
2713        && (qualified
2714            || imports.canonical(compact.split("::").next().unwrap_or_default())
2715                == Some("Migrator"));
2716    let kind = if migrator {
2717        Some(SqlxApiKind::MigrationDirectory)
2718    } else if qualified
2719        || executor_method
2720        || imports.canonical(local).is_some()
2721        || kind == Some(SqlxApiKind::QueryBuilder)
2722    {
2723        kind
2724    } else {
2725        None
2726    };
2727    let Some(kind) = kind else {
2728        return;
2729    };
2730    document.frameworks.push(DataFramework::Sqlx);
2731    let base_line = u32::try_from(zero_based_line)
2732        .unwrap_or(MAX_DATA_SOURCE_LINES)
2733        .saturating_add(1);
2734    let literal = rust_string_literals(arguments, base_line)
2735        .into_iter()
2736        .next();
2737    apply_sqlx_api(kind, literal, base_line, crate_root, owner, document);
2738    if kind == SqlxApiKind::QueryBuilder {
2739        mark_incomplete(document, DataWarning::DynamicQuery);
2740    }
2741}
2742
2743fn inspect_mysql_async_call(
2744    function: &str,
2745    arguments: &str,
2746    zero_based_line: usize,
2747    imports: &MysqlAsyncImports,
2748    owner: Option<&str>,
2749    document: &mut DataDocument,
2750) {
2751    let compact = strip_rust_generics(function);
2752    let method = compact
2753        .rsplit([':', '.'])
2754        .find(|component| !component.is_empty())
2755        .unwrap_or(&compact);
2756    let root = compact.split("::").next().unwrap_or_default();
2757    let imported_trait = imports.canonical(root);
2758    let qualified = imports.is_qualified(&compact);
2759    let method_call = compact.contains('.');
2760    let queryable = mysql_async_queryable_method(method)
2761        && (qualified
2762            || imported_trait == Some("Queryable")
2763            || (method_call && imports.contains("Queryable")));
2764    let required_query_trait = mysql_async_query_trait(method);
2765    let fluent = required_query_trait.is_some()
2766        && (qualified
2767            || imported_trait == required_query_trait
2768            || required_query_trait.is_some_and(|required| imports.contains(required)));
2769    if !queryable && !fluent {
2770        return;
2771    }
2772
2773    let base_line = u32::try_from(zero_based_line)
2774        .unwrap_or(MAX_DATA_SOURCE_LINES)
2775        .saturating_add(1);
2776    let literal = if queryable || qualified || imported_trait.is_some() {
2777        rust_string_literals(arguments, base_line)
2778            .into_iter()
2779            .next()
2780            .or_else(|| mysql_async_fluent_literal(function, method, base_line))
2781    } else {
2782        mysql_async_fluent_literal(function, method, base_line)
2783    };
2784    document.frameworks.push(DataFramework::MysqlAsync);
2785    let Some(literal) = literal else {
2786        mark_incomplete(document, DataWarning::DynamicQuery);
2787        return;
2788    };
2789    if literal.dynamic {
2790        mark_incomplete(document, DataWarning::DynamicQuery);
2791        return;
2792    }
2793    append_inline_sql(&literal.value, evidence(literal.line), owner, document);
2794}
2795
2796fn mysql_async_queryable_method(name: &str) -> bool {
2797    matches!(
2798        name,
2799        "query_iter"
2800            | "prep"
2801            | "exec_iter"
2802            | "query"
2803            | "query_first"
2804            | "query_map"
2805            | "query_fold"
2806            | "query_drop"
2807            | "exec_batch"
2808            | "exec"
2809            | "exec_first"
2810            | "exec_map"
2811            | "exec_fold"
2812            | "exec_drop"
2813            | "query_stream"
2814            | "exec_stream"
2815    )
2816}
2817
2818fn mysql_async_query_trait(name: &str) -> Option<&'static str> {
2819    match name {
2820        "run" | "first" | "fetch" | "reduce" | "map" | "stream" | "ignore" => Some("Query"),
2821        "with" => Some("WithParams"),
2822        "batch" => Some("BatchQuery"),
2823        _ => None,
2824    }
2825}
2826
2827fn mysql_async_fluent_literal(
2828    function: &str,
2829    method: &str,
2830    base_line: u32,
2831) -> Option<SourceLiteral> {
2832    let literal = rust_string_literals(function, base_line)
2833        .into_iter()
2834        .next()?;
2835    let suffix = function.get(literal.end..)?;
2836    let suffix = strip_rust_generics(&compact_rust(suffix));
2837    let suffix = suffix.trim_start_matches(')');
2838    let terminal = format!(".{method}");
2839    (suffix == terminal || (suffix.starts_with(".with(") && suffix.ends_with(&terminal)))
2840        .then_some(literal)
2841}
2842
2843fn inspect_sqlx_macro(
2844    invocation: &str,
2845    zero_based_line: usize,
2846    crate_root: &str,
2847    imports: &SqlxImports,
2848    owner: Option<&str>,
2849    document: &mut DataDocument,
2850) {
2851    let Some((name, arguments)) = invocation.split_once('!') else {
2852        return;
2853    };
2854    let compact = name
2855        .chars()
2856        .filter(|character| !character.is_whitespace())
2857        .collect::<String>();
2858    let local = compact.rsplit("::").next().unwrap_or(&compact);
2859    let qualified = compact.contains("sqlx::");
2860    let canonical = if qualified {
2861        local
2862    } else {
2863        imports.canonical(local).unwrap_or(local)
2864    };
2865    let Some(kind) = sqlx_api_kind(canonical) else {
2866        return;
2867    };
2868    if !qualified && imports.canonical(local).is_none() {
2869        return;
2870    }
2871    document.frameworks.push(DataFramework::Sqlx);
2872    let base_line = u32::try_from(zero_based_line)
2873        .unwrap_or(MAX_DATA_SOURCE_LINES)
2874        .saturating_add(1);
2875    let literal = rust_string_literals(arguments, base_line)
2876        .into_iter()
2877        .next();
2878    apply_sqlx_api(kind, literal, base_line, crate_root, owner, document);
2879}
2880
2881fn apply_sqlx_api(
2882    kind: SqlxApiKind,
2883    literal: Option<SourceLiteral>,
2884    call_line: u32,
2885    crate_root: &str,
2886    owner: Option<&str>,
2887    document: &mut DataDocument,
2888) {
2889    match (kind, literal) {
2890        (SqlxApiKind::Inline | SqlxApiKind::QueryBuilder, Some(literal)) if !literal.dynamic => {
2891            append_inline_sql(&literal.value, evidence(literal.line), owner, document);
2892        }
2893        (SqlxApiKind::QueryFile, Some(literal)) if !literal.dynamic => {
2894            push_sqlx_reference(
2895                DataArtifactReferenceKind::QueryFile,
2896                &literal.value,
2897                evidence(literal.line),
2898                crate_root,
2899                owner,
2900                document,
2901            );
2902        }
2903        (SqlxApiKind::MigrationDirectory, literal) => {
2904            let Some(literal) = literal else {
2905                if let Some(owner) = owner {
2906                    document.owners.push(owner.to_owned());
2907                }
2908                document.references.push(DataArtifactReference {
2909                    framework: DataFramework::Sqlx,
2910                    kind: DataArtifactReferenceKind::SqlxDefaultMigrationDirectory,
2911                    path: crate_root.replace('\\', "/").trim_matches('/').to_owned(),
2912                    owner: owner.map(ToOwned::to_owned),
2913                    evidence: evidence(call_line),
2914                });
2915                return;
2916            };
2917            push_sqlx_reference(
2918                DataArtifactReferenceKind::MigrationDirectory,
2919                &literal.value,
2920                evidence(literal.line),
2921                crate_root,
2922                owner,
2923                document,
2924            );
2925        }
2926        _ => mark_incomplete(document, DataWarning::DynamicQuery),
2927    }
2928}
2929
2930fn append_inline_sql(
2931    sql: &str,
2932    line: DataEvidenceLine,
2933    owner: Option<&str>,
2934    document: &mut DataDocument,
2935) {
2936    let Ok(statements) = Parser::parse_sql(&GenericDialect {}, sql) else {
2937        mark_incomplete(document, DataWarning::SqlParseRecovery);
2938        return;
2939    };
2940    if let Some(owner) = owner {
2941        document.owners.push(owner.to_owned());
2942    }
2943    for statement in &statements {
2944        if matches!(
2945            statement,
2946            Statement::Query(_)
2947                | Statement::Insert(_)
2948                | Statement::Update(_)
2949                | Statement::Delete(_)
2950        ) {
2951            append_statement_accesses(statement, line, owner, document, 0);
2952        } else if !append_schema_statement(statement, line, document) {
2953            mark_incomplete(document, DataWarning::UnsupportedConstruct);
2954        }
2955    }
2956}
2957
2958fn push_sqlx_reference(
2959    kind: DataArtifactReferenceKind,
2960    path: &str,
2961    line: DataEvidenceLine,
2962    crate_root: &str,
2963    owner: Option<&str>,
2964    document: &mut DataDocument,
2965) {
2966    let Some(path) = normalize_data_reference(crate_root, path) else {
2967        mark_incomplete(document, DataWarning::UnresolvedReference);
2968        return;
2969    };
2970    if let Some(owner) = owner {
2971        document.owners.push(owner.to_owned());
2972    }
2973    document.references.push(DataArtifactReference {
2974        framework: DataFramework::Sqlx,
2975        kind,
2976        path,
2977        owner: owner.map(ToOwned::to_owned),
2978        evidence: line,
2979    });
2980}
2981
2982fn strip_rust_generics(value: &str) -> String {
2983    let mut output = String::with_capacity(value.len());
2984    let mut depth = 0_u32;
2985    for character in value.chars().filter(|character| !character.is_whitespace()) {
2986        match character {
2987            '<' => depth = depth.saturating_add(1),
2988            '>' => depth = depth.saturating_sub(1),
2989            _ if depth == 0 => output.push(character),
2990            _ => {}
2991        }
2992    }
2993    output.replace("::::", "::")
2994}
2995
2996fn is_interpolated(
2997    input: &str,
2998    language: SourceLanguage,
2999    start: usize,
3000    quote: u8,
3001    value: &str,
3002) -> bool {
3003    (quote == b'`' && value.contains("${"))
3004        || (language == SourceLanguage::Python
3005            && python_string_prefix(input, start).contains(['f', 'F'])
3006            && value.contains('{')
3007            && value.contains('}'))
3008}
3009
3010fn python_string_prefix(input: &str, start: usize) -> &str {
3011    let bytes = input.as_bytes();
3012    let mut prefix_start = start;
3013    while prefix_start > 0
3014        && bytes[prefix_start - 1].is_ascii_alphabetic()
3015        && start.saturating_sub(prefix_start) < 3
3016    {
3017        prefix_start -= 1;
3018    }
3019    &input[prefix_start..start]
3020}
3021
3022fn sanitize_python_f_string(value: &str) -> Option<String> {
3023    let mut output = String::with_capacity(value.len());
3024    let mut characters = value.char_indices().peekable();
3025    while let Some((_, character)) = characters.next() {
3026        if character == '{' {
3027            if characters.peek().is_some_and(|(_, next)| *next == '{') {
3028                characters.next();
3029                output.push('{');
3030                continue;
3031            }
3032            let mut depth = 1_usize;
3033            let mut quote = None;
3034            let mut escaped = false;
3035            for (_, candidate) in characters.by_ref() {
3036                if escaped {
3037                    escaped = false;
3038                    continue;
3039                }
3040                if candidate == '\\' && quote.is_some() {
3041                    escaped = true;
3042                    continue;
3043                }
3044                if let Some(active) = quote {
3045                    if candidate == active {
3046                        quote = None;
3047                    }
3048                    continue;
3049                }
3050                if matches!(candidate, '\'' | '"') {
3051                    quote = Some(candidate);
3052                } else if candidate == '{' {
3053                    depth = depth.saturating_add(1);
3054                } else if candidate == '}' {
3055                    depth = depth.saturating_sub(1);
3056                    if depth == 0 {
3057                        break;
3058                    }
3059                }
3060            }
3061            if depth != 0 {
3062                return None;
3063            }
3064            output.push_str("__csg_dynamic_value__");
3065        } else if character == '}' && characters.peek().is_some_and(|(_, next)| *next == '}') {
3066            characters.next();
3067            output.push('}');
3068        } else {
3069            output.push(character);
3070        }
3071    }
3072    Some(output)
3073}
3074
3075fn python_format_call_after(input: &str, literal: &SourceLiteral) -> bool {
3076    input
3077        .get(literal.end..)
3078        .is_some_and(|after| after.trim_start().starts_with(".format("))
3079}
3080
3081fn extract_sqlalchemy_accesses(input: &str, document: &mut DataDocument) {
3082    let mut known_models = BTreeSet::new();
3083    for line in input.lines() {
3084        if line.trim_start().starts_with('#') {
3085            continue;
3086        }
3087        if let Some(model) = sqlalchemy_query_model(line) {
3088            known_models.insert(model);
3089        }
3090        for model in sqlalchemy_qualified_constructors(line) {
3091            known_models.insert(model);
3092        }
3093    }
3094    if known_models.is_empty() {
3095        return;
3096    }
3097    document.frameworks.push(DataFramework::SqlAlchemy);
3098    let mut variables = BTreeMap::new();
3099    for (offset, line) in input.lines().enumerate() {
3100        let trimmed = line.trim();
3101        if trimmed.is_empty() || trimmed.starts_with('#') {
3102            continue;
3103        }
3104        let line_number = u32::try_from(offset)
3105            .unwrap_or(MAX_DATA_SOURCE_LINES)
3106            .saturating_add(1);
3107        if let Some((variable, model)) = sqlalchemy_model_assignment(trimmed, &known_models) {
3108            variables.insert(variable, model);
3109        }
3110        if let Some(model) = sqlalchemy_query_model(trimmed) {
3111            append_sqlalchemy_access(
3112                input,
3113                document,
3114                &model,
3115                if trimmed.contains(".update(") || trimmed.contains(".delete(") {
3116                    DataAccessRole::Writer
3117                } else {
3118                    DataAccessRole::Reader
3119                },
3120                if trimmed.contains(".update(") {
3121                    DataOperation::Update
3122                } else if trimmed.contains(".delete(") {
3123                    DataOperation::Delete
3124                } else {
3125                    DataOperation::Select
3126                },
3127                line_number,
3128            );
3129        }
3130        for (marker, operation) in [
3131            (".add(", DataOperation::Insert),
3132            (".delete(", DataOperation::Delete),
3133        ] {
3134            let Some(argument) = call_argument_identifier(trimmed, marker) else {
3135                continue;
3136            };
3137            let model = variables
3138                .get(&argument)
3139                .cloned()
3140                .or_else(|| known_models.contains(&argument).then_some(argument));
3141            if let Some(model) = model {
3142                append_sqlalchemy_access(
3143                    input,
3144                    document,
3145                    &model,
3146                    DataAccessRole::Writer,
3147                    operation,
3148                    line_number,
3149                );
3150            }
3151        }
3152    }
3153}
3154
3155fn append_sqlalchemy_access(
3156    input: &str,
3157    document: &mut DataDocument,
3158    model: &str,
3159    role: DataAccessRole,
3160    operation: DataOperation,
3161    line: u32,
3162) {
3163    let owner = owner_at_line(SourceLanguage::Python, input, line);
3164    if let Some(owner) = owner.as_ref() {
3165        document.owners.push(owner.clone());
3166    }
3167    document.accesses.push(DataAccessObservation {
3168        role,
3169        table: String::new(),
3170        model: Some(model.to_owned()),
3171        owner,
3172        operation: Some(operation),
3173        evidence: evidence(line),
3174    });
3175}
3176
3177fn sqlalchemy_query_model(line: &str) -> Option<String> {
3178    let (_, after) = line.split_once(".query(")?;
3179    python_model_name(after)
3180}
3181
3182fn sqlalchemy_qualified_constructors(line: &str) -> Vec<String> {
3183    let mut output = Vec::new();
3184    let mut remaining = line;
3185    while let Some((_, after)) = remaining.split_once("models.") {
3186        if let Some(model) = python_model_name(after)
3187            && after[model.len()..].trim_start().starts_with('(')
3188        {
3189            output.push(model);
3190        }
3191        remaining = after.get(1..).unwrap_or_default();
3192    }
3193    output
3194}
3195
3196fn sqlalchemy_model_assignment(
3197    line: &str,
3198    known_models: &BTreeSet<String>,
3199) -> Option<(String, String)> {
3200    let (left, right) = line.split_once('=')?;
3201    let variable = left.trim();
3202    if !is_python_identifier(variable) {
3203        return None;
3204    }
3205    let model = python_model_name(right.trim())?;
3206    known_models
3207        .contains(&model)
3208        .then(|| (variable.to_owned(), model))
3209}
3210
3211fn call_argument_identifier(line: &str, marker: &str) -> Option<String> {
3212    let (_, after) = line.split_once(marker)?;
3213    let argument = after
3214        .trim_start()
3215        .split(|character: char| !character.is_ascii_alphanumeric() && character != '_')
3216        .next()?;
3217    is_python_identifier(argument).then(|| argument.to_owned())
3218}
3219
3220fn python_model_name(input: &str) -> Option<String> {
3221    let qualified = input
3222        .trim_start()
3223        .split(|character: char| {
3224            !character.is_ascii_alphanumeric() && character != '_' && character != '.'
3225        })
3226        .next()?;
3227    let model = qualified.rsplit('.').next()?;
3228    is_python_identifier(model).then(|| model.to_owned())
3229}
3230
3231fn is_python_identifier(value: &str) -> bool {
3232    let mut characters = value.chars();
3233    characters
3234        .next()
3235        .is_some_and(|character| character == '_' || character.is_ascii_alphabetic())
3236        && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
3237}
3238
3239fn adjacent_dynamic_operator(input: &str, start: usize, end: usize) -> bool {
3240    let before = input[..start].trim_end();
3241    let after = input[end..].trim_start();
3242    before.ends_with('+')
3243        || after.starts_with('+')
3244        || before.ends_with("format!(")
3245        || after.starts_with(".format(")
3246}
3247
3248fn has_query_context(input: &str, literal: &SourceLiteral) -> bool {
3249    let context_start = floor_char_boundary(input, literal.start.saturating_sub(160));
3250    let before = input[context_start..literal.start].to_ascii_lowercase();
3251    let after_end = floor_char_boundary(input, (literal.end + 80).min(input.len()));
3252    let after = input[literal.end..after_end].to_ascii_lowercase();
3253    [
3254        "query", "execute", "fetch", "select", ".sql(", "sql =", "sql!", "sqlx", "prepare", "raw",
3255    ]
3256    .iter()
3257    .any(|marker| before.contains(marker) || after.contains(marker))
3258}
3259
3260fn floor_char_boundary(input: &str, mut index: usize) -> usize {
3261    while index > 0 && !input.is_char_boundary(index) {
3262        index -= 1;
3263    }
3264    index
3265}
3266
3267fn first_sql_keyword(input: &str) -> Option<String> {
3268    strip_leading_sql_comments(input)
3269        .trim_start()
3270        .split(|character: char| !character.is_ascii_alphabetic())
3271        .find(|value| !value.is_empty())
3272        .map(str::to_ascii_uppercase)
3273}
3274
3275fn strip_leading_sql_comments(mut input: &str) -> &str {
3276    loop {
3277        input = input.trim_start();
3278        if let Some(rest) = input.strip_prefix("--").or_else(|| input.strip_prefix('#')) {
3279            input = rest.split_once('\n').map_or("", |(_, remaining)| remaining);
3280            continue;
3281        }
3282        if let Some(rest) = input.strip_prefix("/*") {
3283            input = rest.split_once("*/").map_or("", |(_, remaining)| remaining);
3284            continue;
3285        }
3286        return input;
3287    }
3288}
3289
3290fn source_has_dynamic_query(input: &str, language: SourceLanguage) -> bool {
3291    input.lines().any(|line| {
3292        let lower = line.to_ascii_lowercase();
3293        let query_call = ["query(", "execute(", "queryrow(", "exec(", ".sql(", "raw("]
3294            .iter()
3295            .any(|marker| lower.contains(marker));
3296        if !query_call {
3297            return false;
3298        }
3299        let has_quote = line.contains('"') || line.contains('\'') || line.contains('`');
3300        !has_quote
3301            || line.contains('+')
3302            || line.contains("${")
3303            || (language == SourceLanguage::Python && (line.contains("f\"") || line.contains("f'")))
3304    })
3305}
3306
3307fn owner_at_line(language: SourceLanguage, input: &str, line: u32) -> Option<String> {
3308    let lines = input
3309        .lines()
3310        .take(usize::try_from(line).ok()?)
3311        .collect::<Vec<_>>();
3312    lines.iter().rev().find_map(|line| {
3313        let trimmed = line.trim();
3314        match language {
3315            SourceLanguage::Rust => function_name_after(trimmed, "fn "),
3316            SourceLanguage::Python => function_name_after(trimmed, "def "),
3317            SourceLanguage::JavaScript | SourceLanguage::TypeScript => {
3318                function_name_after(trimmed, "function ").or_else(|| {
3319                    trimmed
3320                        .split_once('=')
3321                        .filter(|(_, right)| right.contains("=>"))
3322                        .map(|(left, _)| bounded_identifier(left.trim()))
3323                })
3324            }
3325            SourceLanguage::Go => function_name_after(trimmed, "func "),
3326            SourceLanguage::Java => java_method_name(trimmed),
3327        }
3328    })
3329}
3330
3331fn function_name_after(line: &str, marker: &str) -> Option<String> {
3332    let rest = line.strip_prefix(marker)?;
3333    let name = rest
3334        .split(|character: char| character == '(' || character.is_whitespace())
3335        .next()?;
3336    (!name.is_empty()).then(|| bounded_identifier(name))
3337}
3338
3339fn java_method_name(line: &str) -> Option<String> {
3340    if !line.ends_with('{') || !line.contains('(') {
3341        return None;
3342    }
3343    let before = line.split_once('(')?.0;
3344    before
3345        .split_whitespace()
3346        .next_back()
3347        .map(bounded_identifier)
3348}
3349
3350#[cfg(test)]
3351mod tests {
3352    use super::*;
3353
3354    #[test]
3355    fn sql_schema_extracts_columns_indexes_and_foreign_keys() {
3356        let input = r"
3357            CREATE TABLE public.users (
3358                id BIGINT PRIMARY KEY,
3359                email TEXT NOT NULL UNIQUE,
3360                password_hash TEXT DEFAULT 'secret-default'
3361            );
3362            CREATE TABLE public.orders (
3363                id BIGINT PRIMARY KEY,
3364                user_id BIGINT,
3365                CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES public.users(id)
3366            );
3367            CREATE INDEX idx_orders_user ON public.orders(user_id);
3368        ";
3369
3370        let document = extract_data_artifact("db/schema.sql", input).expect("schema should parse");
3371
3372        assert!(
3373            document.tables.iter().any(|table| {
3374                table.name == "orders" && table.indexes.len() == 1 && table.foreign_keys.len() == 1
3375            }),
3376            "orders table should retain index and foreign key"
3377        );
3378    }
3379
3380    #[test]
3381    fn sql_migration_preserves_numeric_order_hint() {
3382        let document = extract_data_artifact(
3383            "migrations/0042_add_users.sql",
3384            "CREATE TABLE users (id INTEGER);",
3385        )
3386        .expect("migration should parse");
3387
3388        assert_eq!(
3389            document
3390                .migration
3391                .and_then(|migration| migration.order_hint),
3392            Some(42)
3393        );
3394    }
3395
3396    #[test]
3397    fn prisma_extracts_mapped_model_and_column() {
3398        let input = r#"
3399            model User {
3400              id Int @id
3401              displayName String @map("display_name")
3402              posts Post[] @relation(fields: [id], references: [userId])
3403              @@map("app_users")
3404              @@schema("tenant")
3405            }
3406        "#;
3407
3408        let document =
3409            extract_data_artifact("prisma/schema.prisma", input).expect("Prisma should parse");
3410
3411        assert!(document.tables.iter().any(|table| {
3412            table.name == "app_users"
3413                && table.schema.as_deref() == Some("tenant")
3414                && table
3415                    .columns
3416                    .iter()
3417                    .any(|column| column.name == "display_name")
3418        }));
3419    }
3420
3421    #[test]
3422    fn alembic_extracts_revision_table_and_added_column() {
3423        let input = r#"
3424revision = "abc123"
3425down_revision = "abc122"
3426def upgrade():
3427    op.create_table(
3428        "users",
3429        sa.Column("id", sa.Integer(), primary_key=True),
3430    )
3431    op.add_column("users", sa.Column("email", sa.String(), nullable=False))
3432def downgrade():
3433    op.drop_table("users")
3434"#;
3435
3436        let document = extract_data_artifact("alembic/versions/0042_users.py", input)
3437            .expect("Alembic should parse");
3438
3439        assert!(document.tables.iter().any(|table| {
3440            table.name == "users" && table.columns.iter().any(|column| column.name == "email")
3441        }));
3442    }
3443
3444    #[test]
3445    fn sqlalchemy_extracts_model_binding_and_foreign_key() {
3446        let input = r#"
3447class Order(Base):
3448    __tablename__ = "orders"
3449    __table_args__ = {"schema": "billing"}
3450    id = Column(Integer, primary_key=True)
3451    user_id = Column(Integer, ForeignKey("public.users.id"), nullable=False)
3452"#;
3453
3454        let document = extract_data_artifact("models.py", input).expect("SQLAlchemy should parse");
3455
3456        assert!(
3457            document.accesses.iter().any(|access| {
3458                access.role == DataAccessRole::ModelBinding
3459                    && access.owner.as_deref() == Some("Order")
3460                    && access.table == "billing.orders"
3461            }) && document.tables.iter().any(|table| {
3462                table.columns.iter().any(|column| column.name == "id")
3463                    && table.foreign_keys.iter().any(|foreign| {
3464                        foreign.columns == ["user_id"] && foreign.referenced_table == "public.users"
3465                    })
3466            })
3467        );
3468    }
3469
3470    #[test]
3471    fn sqlalchemy_nested_enum_does_not_replace_owning_model() {
3472        let input = r#"
3473class Services(Base):
3474    class EnumServiceType(str, enum.Enum):
3475        PUBLIC = "PUBLIC"
3476
3477    __tablename__ = "services"
3478    id = Column(Integer, primary_key=True)
3479"#;
3480
3481        let document = extract_data_artifact("models.py", input).expect("SQLAlchemy should parse");
3482
3483        assert!(document.accesses.iter().any(|access| {
3484            access.role == DataAccessRole::ModelBinding
3485                && access.owner.as_deref() == Some("Services")
3486                && access.table == "services"
3487        }));
3488        assert!(
3489            !document
3490                .owners
3491                .iter()
3492                .any(|owner| owner == "EnumServiceType")
3493        );
3494    }
3495
3496    #[test]
3497    fn diesel_extracts_table_macro() {
3498        let input = r"
3499diesel::table! {
3500    public.users (id) {
3501        id -> Int8,
3502        email -> Text,
3503        nickname -> Nullable<Text>,
3504    }
3505}
3506";
3507
3508        let document = extract_data_artifact("src/schema.rs", input).expect("Diesel should parse");
3509
3510        assert!(document.tables.iter().any(|table| {
3511            table.name == "users"
3512                && table.schema.as_deref() == Some("public")
3513                && table
3514                    .columns
3515                    .iter()
3516                    .any(|column| column.name == "nickname" && column.nullable == Some(true))
3517        }));
3518    }
3519
3520    #[test]
3521    fn literal_select_records_only_direct_tables() {
3522        let input = r#"
3523fn load(pool: &Pool) {
3524    sqlx::query("SELECT u.id FROM users u JOIN teams t ON t.id = u.team_id");
3525}
3526"#;
3527
3528        let document = parse_literal_sql_source(SourceLanguage::Rust, "src/users.rs", input);
3529
3530        assert_eq!(
3531            document
3532                .accesses
3533                .iter()
3534                .map(|access| (&access.role, access.table.as_str()))
3535                .collect::<Vec<_>>(),
3536            vec![
3537                (&DataAccessRole::Reader, "teams"),
3538                (&DataAccessRole::Reader, "users")
3539            ]
3540        );
3541    }
3542
3543    #[test]
3544    fn sqlx_inline_apis_cover_functions_macros_raw_strings_and_raw_sql() {
3545        let input = r##"
3546use sqlx::Executor;
3547
3548async fn load(pool: &sqlx::PgPool) {
3549    sqlx::query!(r#"SELECT id FROM users WHERE id = $1"#, 7_i64);
3550    sqlx::query_as_unchecked!(User, "UPDATE users SET active = true");
3551    sqlx::query_scalar::<_, i64>("SELECT count(*) FROM teams");
3552    sqlx::raw_sql("DELETE FROM sessions; INSERT INTO audits(id) VALUES (1);");
3553    pool.fetch_one("SELECT id FROM accounts");
3554}
3555"##;
3556
3557        let document = parse_literal_sql_source(SourceLanguage::Rust, "src/users.rs", input);
3558        let facts = document
3559            .accesses
3560            .iter()
3561            .map(|access| (access.role, access.operation, access.table.as_str()))
3562            .collect::<BTreeSet<_>>();
3563
3564        assert!(document.frameworks.contains(&DataFramework::Sqlx));
3565        assert!(facts.contains(&(DataAccessRole::Reader, Some(DataOperation::Select), "users")));
3566        assert!(facts.contains(&(DataAccessRole::Writer, Some(DataOperation::Update), "users")));
3567        assert!(facts.contains(&(DataAccessRole::Reader, Some(DataOperation::Select), "teams")));
3568        assert!(facts.contains(&(
3569            DataAccessRole::Writer,
3570            Some(DataOperation::Delete),
3571            "sessions"
3572        )));
3573        assert!(facts.contains(&(
3574            DataAccessRole::Writer,
3575            Some(DataOperation::Insert),
3576            "audits"
3577        )));
3578        assert!(facts.contains(&(
3579            DataAccessRole::Reader,
3580            Some(DataOperation::Select),
3581            "accounts"
3582        )));
3583    }
3584
3585    #[test]
3586    fn rust_sqlx_examples_in_comments_and_string_contents_are_not_evidence() {
3587        let input = r##"
3588fn example() {
3589    // sqlx::query!("SELECT id FROM leaked_comment");
3590    let documentation = r#"sqlx::query!("SELECT id FROM leaked_string")"#;
3591}
3592"##;
3593
3594        let document = parse_literal_sql_source(SourceLanguage::Rust, "src/example.rs", input);
3595
3596        assert!(
3597            document.accesses.is_empty()
3598                && document.frameworks.is_empty()
3599                && document.references.is_empty()
3600        );
3601    }
3602
3603    #[test]
3604    fn mysql_async_queryable_methods_cover_text_prepared_and_streaming_apis() {
3605        let input = r##"
3606use mysql_async::prelude::Queryable as DbQueryable;
3607
3608async fn manage(conn: &mut mysql_async::Conn) {
3609    conn.query_iter(r#"SELECT id FROM users"#).await?;
3610    conn.query_first(b"SELECT id FROM teams").await?;
3611    conn.query_map("SELECT id FROM accounts", |row| row).await?;
3612    conn.query_fold("SELECT id FROM audits", (), |_, _| ()).await?;
3613    conn.query_drop("DELETE FROM sessions").await?;
3614    conn.exec_iter("INSERT INTO events(id) VALUES (1)", ()).await?;
3615    conn.exec_batch("UPDATE jobs SET active = true", [()]).await?;
3616    DbQueryable::exec_drop(conn, "DELETE FROM tokens", ()).await?;
3617    conn.query_stream("SELECT id FROM streams").await?;
3618    conn.exec_stream("SELECT id FROM prepared_streams", ()).await?;
3619    conn.prep("UPDATE prepared_jobs SET active = true").await?;
3620}
3621"##;
3622
3623        let document = parse_literal_sql_source(SourceLanguage::Rust, "src/mysql.rs", input);
3624        let facts = document
3625            .accesses
3626            .iter()
3627            .map(|access| (access.role, access.operation, access.table.as_str()))
3628            .collect::<BTreeSet<_>>();
3629
3630        assert_eq!(document.frameworks, vec![DataFramework::MysqlAsync]);
3631        assert!(facts.contains(&(DataAccessRole::Reader, Some(DataOperation::Select), "users")));
3632        assert!(facts.contains(&(DataAccessRole::Reader, Some(DataOperation::Select), "teams")));
3633        assert!(facts.contains(&(
3634            DataAccessRole::Writer,
3635            Some(DataOperation::Delete),
3636            "sessions"
3637        )));
3638        assert!(facts.contains(&(
3639            DataAccessRole::Writer,
3640            Some(DataOperation::Insert),
3641            "events"
3642        )));
3643        assert!(facts.contains(&(DataAccessRole::Writer, Some(DataOperation::Update), "jobs")));
3644        assert!(facts.contains(&(
3645            DataAccessRole::Writer,
3646            Some(DataOperation::Update),
3647            "prepared_jobs"
3648        )));
3649    }
3650
3651    #[test]
3652    fn mysql_async_fluent_query_traits_cover_aliases_params_and_batch() {
3653        let input = r##"
3654use mysql_async::prelude::{
3655    BatchQuery,
3656    Query as DbQuery,
3657    WithParams,
3658};
3659
3660async fn fluent(conn: &mysql_async::Pool) {
3661    "SELECT id FROM users".first(conn).await?;
3662    br#"SELECT id FROM teams"#.fetch(conn).await?;
3663    "UPDATE jobs SET active = true".with(()).ignore(conn).await?;
3664    "INSERT INTO audits(id) VALUES (1)".with([()]).batch(conn).await?;
3665    DbQuery::run("DELETE FROM sessions", conn).await?;
3666}
3667"##;
3668
3669        let document = parse_literal_sql_source(SourceLanguage::Rust, "src/fluent.rs", input);
3670        let facts = document
3671            .accesses
3672            .iter()
3673            .map(|access| (access.role, access.operation, access.table.as_str()))
3674            .collect::<BTreeSet<_>>();
3675
3676        assert!(document.frameworks.contains(&DataFramework::MysqlAsync));
3677        assert_eq!(
3678            facts,
3679            BTreeSet::from([
3680                (DataAccessRole::Reader, Some(DataOperation::Select), "teams"),
3681                (DataAccessRole::Reader, Some(DataOperation::Select), "users"),
3682                (
3683                    DataAccessRole::Writer,
3684                    Some(DataOperation::Delete),
3685                    "sessions"
3686                ),
3687                (
3688                    DataAccessRole::Writer,
3689                    Some(DataOperation::Insert),
3690                    "audits"
3691                ),
3692                (DataAccessRole::Writer, Some(DataOperation::Update), "jobs"),
3693            ])
3694        );
3695    }
3696
3697    #[test]
3698    fn mysql_async_examples_in_comments_and_string_contents_are_not_evidence() {
3699        let input = r##"
3700fn example() {
3701    // use mysql_async::prelude::*;
3702    // conn.query("SELECT id FROM leaked_comment");
3703    let documentation = r#"conn.exec_drop("DELETE FROM leaked_string", ())"#;
3704}
3705"##;
3706
3707        let document = parse_literal_sql_source(SourceLanguage::Rust, "src/example.rs", input);
3708
3709        assert!(document.accesses.is_empty() && document.frameworks.is_empty());
3710    }
3711
3712    #[test]
3713    fn sqlx_query_file_macro_matrix_resolves_from_cargo_crate_root() {
3714        let input = r#"
3715async fn load() {
3716    sqlx::query_file!("queries/one.sql");
3717    sqlx::query_file_unchecked!("queries/two.sql");
3718    sqlx::query_file_as!(User, "queries/three.sql");
3719    sqlx::query_file_as_unchecked!(User, "queries/four.sql");
3720    sqlx::query_file_scalar!("queries/five.sql");
3721    sqlx::query_file_scalar_unchecked!("queries/six.sql");
3722}
3723"#;
3724
3725        let document = parse_literal_sql_source_at_root(
3726            SourceLanguage::Rust,
3727            "crates/api/src/users.rs",
3728            "crates/api",
3729            input,
3730        );
3731
3732        assert_eq!(
3733            document
3734                .references
3735                .iter()
3736                .map(|reference| reference.path.as_str())
3737                .collect::<Vec<_>>(),
3738            vec![
3739                "crates/api/queries/five.sql",
3740                "crates/api/queries/four.sql",
3741                "crates/api/queries/one.sql",
3742                "crates/api/queries/six.sql",
3743                "crates/api/queries/three.sql",
3744                "crates/api/queries/two.sql",
3745            ]
3746        );
3747        assert!(document.references.iter().all(|reference| {
3748            reference.framework == DataFramework::Sqlx
3749                && reference.kind == DataArtifactReferenceKind::QueryFile
3750                && reference.owner.as_deref() == Some("load")
3751        }));
3752    }
3753
3754    #[test]
3755    fn sqlx_migration_apis_resolve_default_custom_and_runtime_directories() {
3756        let input = r#"
3757use sqlx::migrate::Migrator;
3758
3759static DEFAULT: Migrator = sqlx::migrate!();
3760static CUSTOM: Migrator = sqlx::migrate!("db/migrations");
3761
3762async fn runtime() {
3763    Migrator::new(std::path::Path::new("tenant/migrations")).await;
3764}
3765"#;
3766
3767        let document = parse_literal_sql_source_at_root(
3768            SourceLanguage::Rust,
3769            "crates/api/src/lib.rs",
3770            "crates/api",
3771            input,
3772        );
3773
3774        assert_eq!(
3775            document
3776                .references
3777                .iter()
3778                .filter(|reference| {
3779                    reference.kind == DataArtifactReferenceKind::MigrationDirectory
3780                })
3781                .map(|reference| reference.path.as_str())
3782                .collect::<Vec<_>>(),
3783            vec!["crates/api/db/migrations", "crates/api/tenant/migrations",]
3784        );
3785        assert!(document.references.iter().any(|reference| {
3786            reference.kind == DataArtifactReferenceKind::SqlxDefaultMigrationDirectory
3787                && reference.path == "crates/api"
3788        }));
3789    }
3790
3791    #[test]
3792    fn sqlx_configuration_extracts_migration_directory_without_leaking_other_values() {
3793        let document = extract_data_artifact(
3794            "crates/api/sqlx.toml",
3795            r#"
3796[database]
3797url = "postgres://secret@example.invalid/private"
3798
3799[migrate]
3800table-name = "app._sqlx_migrations"
3801migrations-dir = "db/migrations" # relative to the crate root
3802
3803[migrate.defaults]
3804migration-type = "reversible"
3805"#,
3806        )
3807        .expect("SQLx configuration should parse");
3808
3809        assert_eq!(document.artifact_kind, DataArtifactKind::SqlxConfiguration);
3810        assert_eq!(document.frameworks, vec![DataFramework::Sqlx]);
3811        assert_eq!(document.references.len(), 1);
3812        assert_eq!(
3813            document.references[0].kind,
3814            DataArtifactReferenceKind::MigrationDirectory
3815        );
3816        assert_eq!(document.references[0].path, "crates/api/db/migrations");
3817        assert!(!format!("{document:?}").contains("secret"));
3818    }
3819
3820    #[test]
3821    fn sqlx_import_alias_and_query_builder_are_recognized_conservatively() {
3822        let input = r#"
3823use sqlx::{query as sql_query, QueryBuilder as SqlBuilder};
3824
3825fn load() {
3826    sql_query("SELECT id FROM users");
3827    let mut builder = SqlBuilder::<sqlx::Postgres>::new("SELECT id FROM teams");
3828    builder.push(" WHERE id = ").push_bind(7_i64);
3829}
3830"#;
3831
3832        let document = parse_literal_sql_source(SourceLanguage::Rust, "src/users.rs", input);
3833
3834        assert!(
3835            document
3836                .accesses
3837                .iter()
3838                .any(|access| { access.table == "users" && access.role == DataAccessRole::Reader })
3839        );
3840        assert!(
3841            document
3842                .accesses
3843                .iter()
3844                .any(|access| { access.table == "teams" && access.role == DataAccessRole::Reader })
3845        );
3846        assert!(
3847            document.incomplete
3848                && document.warnings.contains(&DataWarning::DynamicQuery)
3849                && document.frameworks == vec![DataFramework::Sqlx]
3850        );
3851    }
3852
3853    #[test]
3854    fn standalone_query_files_are_not_misclassified_as_migrations() {
3855        let document = extract_data_artifact(
3856            "queries/find_users.sql",
3857            "SELECT id FROM users WHERE active = true;",
3858        )
3859        .expect("query file should parse");
3860
3861        assert!(
3862            document.artifact_kind == DataArtifactKind::SqlQueryFile
3863                && document.migration.is_none()
3864                && document.accesses.iter().any(|access| {
3865                    access.table == "users" && access.role == DataAccessRole::Reader
3866                })
3867        );
3868    }
3869
3870    #[test]
3871    fn sqlx_reversible_migration_filename_sets_version_metadata() {
3872        let document = extract_data_artifact(
3873            "migrations/0042_add_users.up.sql",
3874            "CREATE TABLE users (id INTEGER);",
3875        )
3876        .expect("migration should parse");
3877        let migration = document.migration.expect("migration metadata");
3878
3879        assert!(
3880            migration.order_hint == Some(42)
3881                && migration.revision.as_deref() == Some("42")
3882                && migration.reversible
3883        );
3884    }
3885
3886    #[test]
3887    fn literal_write_records_writer_role() {
3888        let input = r#"
3889async def save(conn):
3890    await conn.execute("UPDATE users SET active = ? WHERE id = ?", True, 7)
3891"#;
3892
3893        let document = parse_literal_sql_source(SourceLanguage::Python, "users.py", input);
3894
3895        assert!(document.accesses.iter().any(|access| {
3896            access.role == DataAccessRole::Writer
3897                && access.operation == Some(DataOperation::Update)
3898                && access.table == "users"
3899        }));
3900    }
3901
3902    #[test]
3903    fn pymysql_wrapper_f_string_preserves_static_table_access() {
3904        let input = r#"
3905from src.clases.Database import Database
3906
3907def load(account_id):
3908    return Database.sql(f"SELECT ID FROM accounts WHERE ID = {account_id}")
3909"#;
3910
3911        let document = parse_literal_sql_source(SourceLanguage::Python, "accounts.py", input);
3912
3913        assert!(document.accesses.iter().any(|access| {
3914            access.role == DataAccessRole::Reader
3915                && access.operation == Some(DataOperation::Select)
3916                && access.table == "accounts"
3917        }));
3918        assert!(
3919            document.incomplete && document.warnings.contains(&DataWarning::DynamicQuery),
3920            "interpolated values must remain explicitly incomplete"
3921        );
3922    }
3923
3924    #[test]
3925    fn pymysql_wrapper_does_not_promote_interpolated_table_names() {
3926        let input = r#"
3927from src.clases.Database import Database
3928
3929def load(table):
3930    return Database.sql(f"SELECT ID FROM {table} WHERE active = 1")
3931"#;
3932
3933        let document = parse_literal_sql_source(SourceLanguage::Python, "accounts.py", input);
3934
3935        assert!(
3936            document.accesses.is_empty()
3937                && document.incomplete
3938                && document.warnings.contains(&DataWarning::DynamicQuery)
3939        );
3940    }
3941
3942    #[test]
3943    fn psycopg_format_values_preserve_static_table_access() {
3944        let input = r#"
3945import psycopg2
3946
3947def search(cursor, value):
3948    cursor.execute("""
3949        SELECT id FROM embeddings WHERE content = '{}'
3950    """.format(value))
3951"#;
3952
3953        let document = parse_literal_sql_source(SourceLanguage::Python, "vectors.py", input);
3954
3955        assert!(document.frameworks.contains(&DataFramework::Psycopg));
3956        assert!(document.accesses.iter().any(|access| {
3957            access.role == DataAccessRole::Reader
3958                && access.table == "embeddings"
3959                && access.owner.as_deref() == Some("search")
3960        }));
3961        assert!(document.warnings.contains(&DataWarning::DynamicQuery));
3962    }
3963
3964    #[test]
3965    fn sqlalchemy_source_records_model_reads_and_writes_without_guessing_tables() {
3966        let input = r#"
3967from sqlalchemy.orm import Session
3968from app import models
3969
3970def create(db: Session):
3971    service = models.Services(name="test")
3972    db.add(service)
3973
3974def list_all(db: Session):
3975    return db.query(models.Services).all()
3976"#;
3977
3978        let document = parse_literal_sql_source(SourceLanguage::Python, "controllers.py", input);
3979
3980        assert!(document.frameworks.contains(&DataFramework::SqlAlchemy));
3981        assert!(document.accesses.iter().any(|access| {
3982            access.role == DataAccessRole::Writer
3983                && access.model.as_deref() == Some("Services")
3984                && access.operation == Some(DataOperation::Insert)
3985        }));
3986        assert!(document.accesses.iter().any(|access| {
3987            access.role == DataAccessRole::Reader
3988                && access.model.as_deref() == Some("Services")
3989                && access.operation == Some(DataOperation::Select)
3990        }));
3991        assert!(
3992            document
3993                .accesses
3994                .iter()
3995                .all(|access| access.table.is_empty())
3996        );
3997    }
3998
3999    #[test]
4000    fn mariadb_dump_recovers_create_tables_after_unsupported_statements() {
4001        let input = r"
4002/*!40101 SET NAMES utf8mb4 */;
4003CREATE DATABASE IF NOT EXISTS `processor`;
4004USE `processor`;
4005
4006CREATE TABLE IF NOT EXISTS `accounts` (
4007  `ID` int(10) unsigned NOT NULL AUTO_INCREMENT,
4008  `number` int(10) unsigned NOT NULL DEFAULT 0,
4009  PRIMARY KEY (`ID`),
4010  UNIQUE KEY `number_unique` (`number`)
4011) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
4012
4013LOCK TABLES `accounts` WRITE;
4014";
4015
4016        let document =
4017            extract_data_artifact("sql/base_embozador.sql", input).expect("dump should recover");
4018
4019        assert!(document.tables.iter().any(|table| {
4020            table.name == "accounts"
4021                && table.columns.iter().any(|column| column.name == "ID")
4022                && table.columns.iter().any(|column| column.name == "number")
4023        }));
4024        assert!(
4025            document.incomplete
4026                && document.warnings.iter().any(|warning| {
4027                    matches!(
4028                        warning,
4029                        DataWarning::SqlParseRecovery | DataWarning::UnsupportedConstruct
4030                    )
4031                }),
4032            "recovered dumps must retain the fact that unsupported statements were skipped"
4033        );
4034    }
4035
4036    #[test]
4037    fn literal_queries_cover_remaining_source_languages() {
4038        let cases = [
4039            (
4040                SourceLanguage::JavaScript,
4041                r#"function load() { db.query("SELECT id FROM users"); }"#,
4042            ),
4043            (
4044                SourceLanguage::TypeScript,
4045                r#"const load = () => db.query("SELECT id FROM users");"#,
4046            ),
4047            (
4048                SourceLanguage::Go,
4049                "func load() { db.Query(`SELECT id FROM users`) }",
4050            ),
4051            (
4052                SourceLanguage::Java,
4053                r#"void load() { statement.executeQuery("SELECT id FROM users"); }"#,
4054            ),
4055        ];
4056
4057        assert!(cases.into_iter().all(|(language, source)| {
4058            parse_literal_sql_source(language, "source.file", source)
4059                .accesses
4060                .iter()
4061                .any(|access| access.table == "users" && access.role == DataAccessRole::Reader)
4062        }));
4063    }
4064
4065    #[test]
4066    fn literal_query_context_should_preserve_utf8_boundaries() {
4067        let input = format!(
4068            "{} db.query(\"SELECT id FROM users\") {}",
4069            "─".repeat(70),
4070            "─".repeat(40)
4071        );
4072
4073        let document = parse_literal_sql_source(SourceLanguage::JavaScript, "src/users.js", &input);
4074
4075        assert!(
4076            document
4077                .accesses
4078                .iter()
4079                .any(|access| access.table == "users")
4080        );
4081    }
4082
4083    #[test]
4084    fn dynamic_query_is_incomplete_and_unlinked() {
4085        let input = r#"
4086fn load(pool: &Pool, table: &str) {
4087    let sql = "SELECT * FROM ".to_owned() + table;
4088    sqlx::query(&sql);
4089}
4090"#;
4091
4092        let document = parse_literal_sql_source(SourceLanguage::Rust, "src/dynamic.rs", input);
4093
4094        assert!(
4095            document.incomplete
4096                && document.accesses.is_empty()
4097                && document.warnings.contains(&DataWarning::DynamicQuery)
4098        );
4099    }
4100
4101    #[test]
4102    fn serialization_does_not_persist_secrets_or_sql_bodies() {
4103        let input = r"
4104-- postgres://admin:credential@localhost/private
4105CREATE TABLE users (
4106    id INTEGER,
4107    token TEXT DEFAULT 'top-secret-token'
4108);
4109";
4110        let document =
4111            extract_data_artifact("schema.sql", input).expect("schema should parse safely");
4112        let serialized = serde_json::to_string(&document).expect("document should serialize");
4113
4114        assert!(
4115            !serialized.contains("credential")
4116                && !serialized.contains("top-secret-token")
4117                && !serialized.contains("CREATE TABLE")
4118                && !serialized.contains("postgres://")
4119        );
4120    }
4121
4122    #[test]
4123    fn literal_query_serialization_discards_values_and_body() {
4124        let input = r#"db.query("SELECT id FROM users WHERE token = 'literal-secret-value'")"#;
4125        let document = parse_literal_sql_source(SourceLanguage::JavaScript, "src/users.js", input);
4126        let serialized = serde_json::to_string(&document).expect("document should serialize");
4127
4128        assert!(
4129            !serialized.contains("literal-secret-value")
4130                && !serialized.contains("SELECT id")
4131                && serialized.contains("users")
4132        );
4133    }
4134
4135    #[test]
4136    fn parser_recovery_is_incomplete_without_echoing_secret_input() {
4137        let document = extract_data_artifact(
4138            "schema.sql",
4139            "CREATE TABLE secret (token DEFAULT 'do-not-echo'",
4140        )
4141        .expect("unsupported SQL syntax should degrade");
4142        let serialized = serde_json::to_string(&document).expect("document should serialize");
4143
4144        assert!(
4145            document.incomplete
4146                && document.warnings.contains(&DataWarning::SqlParseRecovery)
4147                && !serialized.contains("do-not-echo")
4148        );
4149    }
4150
4151    #[test]
4152    fn output_is_deterministic_and_deduplicated() {
4153        let input = "CREATE TABLE b (id INT); CREATE TABLE a (id INT);";
4154        let first = extract_data_artifact("schema.sql", input).expect("schema should parse");
4155        let second = extract_data_artifact("schema.sql", input).expect("schema should parse");
4156
4157        assert_eq!(first, second);
4158    }
4159
4160    #[test]
4161    fn unsupported_filename_is_rejected_without_path_echo() {
4162        let error = extract_data_artifact("secrets/config.txt", "password=private")
4163            .expect_err("unsupported file should fail");
4164
4165        assert_eq!(error, DataExtractionError::UnsupportedArtifact);
4166    }
4167
4168    #[test]
4169    fn oversized_input_returns_bounded_incomplete_document() {
4170        let input = "x".repeat(MAX_DATA_INPUT_BYTES + 1);
4171        let document =
4172            extract_data_artifact("schema.sql", &input).expect("oversized SQL should degrade");
4173
4174        assert!(
4175            document.incomplete
4176                && document.warnings == vec![DataWarning::LimitExceeded]
4177                && document.tables.is_empty()
4178        );
4179    }
4180
4181    #[test]
4182    fn oversized_schema_retains_tables_and_truncates_details() {
4183        let mut document = empty_document("schema.sql", DataArtifactKind::DeclarativeSqlSchema);
4184        for table_index in 0..5 {
4185            let mut table = table_from_qualified_text(
4186                &format!("table_{table_index}"),
4187                evidence(table_index + 1),
4188            );
4189            table.columns = (0..1_000)
4190                .map(|column_index| DatabaseColumn {
4191                    name: format!("column_{column_index}"),
4192                    data_type: Some("integer".to_owned()),
4193                    nullable: None,
4194                    primary_key: false,
4195                    unique: false,
4196                    default_present: false,
4197                    evidence: evidence(table_index + 1),
4198                })
4199                .collect();
4200            document.tables.push(table);
4201        }
4202
4203        finish_document(&mut document);
4204
4205        assert!(
4206            document.tables.len() == 5
4207                && document
4208                    .tables
4209                    .iter()
4210                    .all(|table| table.name.starts_with("table_"))
4211                && structured_item_count(&document) == MAX_DATA_ITEMS
4212                && document.incomplete
4213                && document.warnings.contains(&DataWarning::LimitExceeded)
4214        );
4215    }
4216
4217    #[test]
4218    fn evidence_line_enforces_bounds() {
4219        assert!(DataEvidenceLine::new(0).is_err());
4220        assert!(DataEvidenceLine::new(MAX_DATA_SOURCE_LINES).is_ok());
4221        assert!(DataEvidenceLine::new(MAX_DATA_SOURCE_LINES + 1).is_err());
4222    }
4223}