pushkin-core 0.2.0

Core envelope, manifest, pipeline, and waiver types for the pushkin write-gate
Documentation
//! Deterministic symbol-level mapper (spec ยง7.1): manifest glob โ†’
//! contract, refined to the exported contract symbols a written file
//! actually references. Resolution happens once at the boundary into
//! canonical contract names; ambiguity and misses are structured errors
//! with candidates. Deterministic by crate contract โ€” tree-sitter
//! parsing only, no retrieval, never load-bearing for gates: a file
//! that cannot be parsed degrades to an empty symbol list, which means
//! "deliver the full contract", never a lost gate decision.

use std::collections::BTreeSet;

use thiserror::Error;

use crate::manifest::{levenshtein, Contract, ContractName, Manifest};

#[derive(Debug, Error)]
pub enum ReferenceError {
    #[error("contract reference '{reference}' is ambiguous; use one of: {candidates}")]
    Ambiguous {
        reference: String,
        candidates: String,
    },
    #[error("unknown contract '{reference}'; declared contracts: {candidates}")]
    Unknown {
        reference: String,
        candidates: String,
    },
}

/// The symbol-level slice of one contract for one write.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContractSlice {
    pub contract: ContractName,
    /// Exported contract symbols the written content references. Empty
    /// means the file references none yet โ€” deliver the full contract.
    pub symbols: Vec<String>,
}

/// Resolves a shorthand contract reference (canonical name, source path,
/// or source basename) to the declared name, once, at the boundary.
///
/// # Errors
/// `ReferenceError::Ambiguous` when the shorthand matches more than one
/// declared contract; `ReferenceError::Unknown` (with nearest candidates)
/// when it matches none.
pub fn resolve_reference<'m>(
    manifest: &'m Manifest,
    reference: &str,
) -> Result<&'m ContractName, ReferenceError> {
    if let Some(exact) = manifest
        .contracts
        .iter()
        .find(|contract| contract.name.as_str() == reference)
    {
        return Ok(&exact.name);
    }
    let matches: Vec<&Contract> = manifest
        .contracts
        .iter()
        .filter(|contract| shorthand_matches(contract, reference))
        .collect();
    match matches.as_slice() {
        [single] => Ok(&single.name),
        [] => Err(ReferenceError::Unknown {
            reference: reference.to_owned(),
            candidates: nearest_contracts(manifest, reference),
        }),
        several => Err(ReferenceError::Ambiguous {
            reference: reference.to_owned(),
            candidates: several
                .iter()
                .map(|contract| contract.name.as_str())
                .collect::<Vec<_>>()
                .join(", "),
        }),
    }
}

fn shorthand_matches(contract: &Contract, reference: &str) -> bool {
    contract.name.as_str().starts_with(reference)
        || contract.source == reference
        || contract.source.rsplit('/').next() == Some(reference)
}

fn nearest_contracts(manifest: &Manifest, reference: &str) -> String {
    let mut scored: Vec<(usize, &str)> = manifest
        .contracts
        .iter()
        .map(|contract| {
            (
                levenshtein(reference, contract.name.as_str()),
                contract.name.as_str(),
            )
        })
        .collect();
    scored.sort_unstable();
    scored
        .into_iter()
        .map(|(_, name)| name)
        .collect::<Vec<_>>()
        .join(", ")
}

/// Symbol-level slices for a write of `content` at `path`.
/// `contract_sources` supplies each declared contract's authoring source
/// text (the mapper is pure โ€” callers own file access).
#[must_use]
pub fn slices_for_write(
    manifest: &Manifest,
    path: &str,
    content: &str,
    contract_sources: &[(&str, &str)],
) -> Vec<ContractSlice> {
    let Some(mapping) = manifest.mapping_for(path) else {
        return Vec::new();
    };
    let referenced = identifiers(language_for_path(path), content);
    mapping
        .contracts
        .iter()
        .map(|name| ContractSlice {
            contract: name.clone(),
            symbols: touched_symbols(manifest, name, contract_sources, &referenced),
        })
        .collect()
}

/// Exported symbols of `name`'s contract source that `referenced` uses,
/// in the contract's own declaration order (deterministic).
fn touched_symbols(
    manifest: &Manifest,
    name: &ContractName,
    contract_sources: &[(&str, &str)],
    referenced: &BTreeSet<String>,
) -> Vec<String> {
    let source_language = manifest
        .contracts
        .iter()
        .find(|contract| contract.name == *name)
        .and_then(|contract| language_for_path(&contract.source));
    let Some(source_text) = contract_sources
        .iter()
        .find(|(source_name, _)| *source_name == name.as_str())
        .map(|(_, text)| *text)
    else {
        return Vec::new();
    };
    exported_symbols(source_language, source_text)
        .into_iter()
        .filter(|symbol| referenced.contains(symbol))
        .collect()
}

// ---------- tree-sitter internals ----------

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SourceLanguage {
    TypeScript,
    Python,
    Rust,
    Sql,
}

fn language_for_path(path: &str) -> Option<SourceLanguage> {
    let extension = path.rsplit('.').next()?;
    match extension {
        "ts" | "tsx" | "mts" | "cts" => Some(SourceLanguage::TypeScript),
        "py" => Some(SourceLanguage::Python),
        "rs" => Some(SourceLanguage::Rust),
        "sql" => Some(SourceLanguage::Sql),
        _ => None,
    }
}

fn grammar(language: SourceLanguage) -> tree_sitter::Language {
    match language {
        SourceLanguage::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
        SourceLanguage::Python => tree_sitter_python::LANGUAGE.into(),
        SourceLanguage::Rust => tree_sitter_rust::LANGUAGE.into(),
        SourceLanguage::Sql => tree_sitter_sequel::LANGUAGE.into(),
    }
}

/// Parses `text`; `None` on any parser failure โ€” degradation, never a
/// lost gate decision (the mapper is advisory by construction).
fn parse(language: Option<SourceLanguage>, text: &str) -> Option<tree_sitter::Tree> {
    let mut parser = tree_sitter::Parser::new();
    parser.set_language(&grammar(language?)).ok()?;
    parser.parse(text, None)
}

/// Every node in the tree, breadth-first (deterministic order).
fn descendants(root: tree_sitter::Node<'_>) -> Vec<tree_sitter::Node<'_>> {
    let mut nodes = vec![root];
    let mut index = 0;
    while index < nodes.len() {
        let node = nodes[index];
        let mut cursor = node.walk();
        nodes.extend(node.children(&mut cursor));
        index += 1;
    }
    nodes
}

const IDENTIFIER_KINDS: &[&str] = &[
    "identifier",
    "type_identifier",
    "property_identifier",
    "shorthand_property_identifier",
];

/// All identifier-leaf texts in `text` (empty set when unparseable).
fn identifiers(language: Option<SourceLanguage>, text: &str) -> BTreeSet<String> {
    let Some(tree) = parse(language, text) else {
        return BTreeSet::new();
    };
    descendants(tree.root_node())
        .into_iter()
        .filter(|node| IDENTIFIER_KINDS.contains(&node.kind()))
        .filter_map(|node| node.utf8_text(text.as_bytes()).ok())
        .map(str::to_owned)
        .collect()
}

/// Node kinds whose `name` field declares an exported/public symbol.
const DECLARATION_KINDS: &[&str] = &[
    // TypeScript (inside export_statement)
    "variable_declarator",
    "function_declaration",
    "class_declaration",
    "interface_declaration",
    "type_alias_declaration",
    "enum_declaration",
    "export_specifier",
    // Python (module level)
    "function_definition",
    "class_definition",
    // Rust (pub items)
    "function_item",
    "struct_item",
    "enum_item",
    "const_item",
    "static_item",
    "type_item",
];

/// Declared symbol names in declaration order. TypeScript counts only
/// declarations under an `export_statement`; Python counts module-level
/// definitions; Rust counts `pub` items; SQL has no symbol exports.
fn exported_symbols(language: Option<SourceLanguage>, text: &str) -> Vec<String> {
    let Some(tree) = parse(language, text) else {
        return Vec::new();
    };
    let mut symbols = Vec::new();
    for node in descendants(tree.root_node()) {
        if !DECLARATION_KINDS.contains(&node.kind()) || !is_exported(language, node) {
            continue;
        }
        let named = node
            .child_by_field_name("name")
            .and_then(|name| name.utf8_text(text.as_bytes()).ok());
        if let Some(symbol) = named {
            if !symbols.iter().any(|existing| existing == symbol) {
                symbols.push(symbol.to_owned());
            }
        }
    }
    symbols
}

/// Whether a declaration node is externally visible for its language.
fn is_exported(language: Option<SourceLanguage>, node: tree_sitter::Node<'_>) -> bool {
    match language {
        Some(SourceLanguage::TypeScript) => {
            let mut ancestor = node.parent();
            while let Some(current) = ancestor {
                if current.kind() == "export_statement" {
                    return true;
                }
                ancestor = current.parent();
            }
            false
        }
        Some(SourceLanguage::Python) => true,
        Some(SourceLanguage::Rust) => node
            .child(0)
            .is_some_and(|first| first.kind() == "visibility_modifier"),
        Some(SourceLanguage::Sql) | None => false,
    }
}