use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use salsa::{Durability, Setter};
use crate::index::PackageIndex;
use crate::parser::{ParseDiagnostic, parse};
use crate::project::{self, IncludeEdge};
use crate::resolve::{
Candidate, ModulePath, Namespace, OccurrenceKey, OccurrenceRec, PackageSource, Resolution,
Resolver,
};
use crate::semantic::SemanticModel;
use crate::syntax::SyntaxNode;
use rowan::TextSize;
use smol_str::SmolStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct FileId(pub u32);
#[salsa::input]
pub struct SourceFile {
pub id: FileId,
#[returns(ref)]
pub path: Option<PathBuf>,
#[returns(ref)]
pub text: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LibraryPackages(pub BTreeMap<String, Arc<PackageIndex>>);
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LibraryRoots(pub BTreeMap<String, PathBuf>);
#[salsa::input(singleton)]
pub struct LibraryIndex {
#[returns(ref)]
pub packages: LibraryPackages,
#[returns(ref)]
pub roots: LibraryRoots,
#[returns(ref)]
pub workspaces: Vec<String>,
}
#[salsa::input(singleton)]
pub struct WorkspaceFiles {
#[returns(ref)]
pub files: Vec<SourceFile>,
}
#[derive(Debug, Clone)]
pub struct ParsedDocument {
pub green: rowan::GreenNode,
pub diagnostics: Vec<ParseDiagnostic>,
}
#[salsa::db]
pub trait IncrementalDb: salsa::Database {}
#[salsa::tracked(returns(ref), no_eq)]
pub fn parsed_document(db: &dyn IncrementalDb, file: SourceFile) -> ParsedDocument {
let text = file.text(db);
let parsed = parse(text.as_str());
ParsedDocument {
green: parsed.cst.green().into_owned(),
diagnostics: parsed.diagnostics,
}
}
pub fn parse_diagnostics(db: &dyn IncrementalDb, file: SourceFile) -> &[ParseDiagnostic] {
&parsed_document(db, file).diagnostics
}
pub fn parsed_tree_root(db: &dyn IncrementalDb, file: SourceFile) -> SyntaxNode {
SyntaxNode::new_root(parsed_document(db, file).green.clone())
}
#[salsa::tracked(returns(ref))]
pub fn semantic_model(db: &dyn IncrementalDb, file: SourceFile) -> SemanticModel {
SemanticModel::build(&parsed_tree_root(db, file))
}
#[salsa::tracked(returns(ref))]
pub fn file_exports(db: &dyn IncrementalDb, file: SourceFile) -> BTreeSet<String> {
project::file_exports(semantic_model(db, file))
}
#[salsa::tracked(returns(ref))]
pub fn file_free_reads(db: &dyn IncrementalDb, file: SourceFile) -> BTreeSet<String> {
project::file_free_reads(semantic_model(db, file))
}
#[salsa::tracked(returns(ref))]
pub fn file_qualified_reads(db: &dyn IncrementalDb, file: SourceFile) -> BTreeSet<String> {
project::file_qualified_reads(semantic_model(db, file))
}
#[salsa::tracked(returns(ref))]
pub fn include_edges(db: &dyn IncrementalDb, file: SourceFile) -> Vec<IncludeEdge> {
let root = parsed_tree_root(db, file);
let base_dir = file.path(db).as_deref().and_then(Path::parent);
project::include_edges(&root, base_dir)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnresolvedInclude {
pub from: PathBuf,
pub raw: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CycleEdge {
pub from: PathBuf,
pub raw: String,
pub to: PathBuf,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProjectGraph {
pub nodes: Vec<PathBuf>,
pub forward: BTreeMap<PathBuf, Vec<PathBuf>>,
pub reverse: BTreeMap<PathBuf, Vec<PathBuf>>,
pub host_modules: BTreeMap<PathBuf, Vec<String>>,
pub cycles: Vec<CycleEdge>,
pub unresolved: Vec<UnresolvedInclude>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum IncludeColor {
Gray,
Black,
}
#[salsa::tracked(returns(ref))]
pub fn project_graph(db: &dyn IncrementalDb) -> ProjectGraph {
let mut graph = ProjectGraph::default();
let (Some(wf), Some(lib)) = (WorkspaceFiles::try_get(db), LibraryIndex::try_get(db)) else {
return graph;
};
let mut names = lib.workspaces(db).clone();
names.sort();
let mut by_path: HashMap<PathBuf, SourceFile> = HashMap::new();
for &file in wf.files(db) {
if let Some(path) = file.path(db) {
by_path.insert(normalize_path(path), file);
}
}
let mut color: HashMap<PathBuf, IncludeColor> = HashMap::new();
for name in names {
let Some(root) = lib.roots(db).0.get(&name).cloned() else {
continue;
};
let entry = normalize_path(&root.join("src").join(format!("{name}.jl")));
let Some(&entry_file) = by_path.get(&entry) else {
continue; };
if color.contains_key(&entry) {
continue; }
walk_include_graph(
db,
&by_path,
entry,
entry_file,
Vec::new(),
&name,
&mut color,
&mut graph,
);
}
graph
}
#[allow(clippy::too_many_arguments)]
fn walk_include_graph(
db: &dyn IncrementalDb,
by_path: &HashMap<PathBuf, SourceFile>,
path: PathBuf,
file: SourceFile,
host: Vec<String>,
pkg: &str,
color: &mut HashMap<PathBuf, IncludeColor>,
graph: &mut ProjectGraph,
) {
color.insert(path.clone(), IncludeColor::Gray);
graph.nodes.push(path.clone());
graph.host_modules.insert(path.clone(), host.clone());
for edge in include_edges(db, file) {
let target = match &edge.target {
Some(target) => normalize_path(target),
None => {
graph.unresolved.push(UnresolvedInclude {
from: path.clone(),
raw: edge.raw.clone(),
});
continue;
}
};
let Some(&child_file) = by_path.get(&target) else {
graph.unresolved.push(UnresolvedInclude {
from: path.clone(),
raw: edge.raw.clone(),
});
continue;
};
graph
.forward
.entry(path.clone())
.or_default()
.push(target.clone());
graph
.reverse
.entry(target.clone())
.or_default()
.push(path.clone());
let mut child_host = host.clone();
child_host.extend(edge.host_suffix.iter().cloned());
if host.is_empty() && child_host.first().map(String::as_str) == Some(pkg) {
child_host.remove(0);
}
match color.get(&target) {
Some(IncludeColor::Gray) => graph.cycles.push(CycleEdge {
from: path.clone(),
raw: edge.raw.clone(),
to: target,
}),
Some(IncludeColor::Black) => {}
None => walk_include_graph(
db, by_path, target, child_file, child_host, pkg, color, graph,
),
}
}
color.insert(path, IncludeColor::Black);
}
#[salsa::tracked(returns(ref))]
pub fn host_module_of(db: &dyn IncrementalDb, file: SourceFile) -> Vec<String> {
let Some(path) = file.path(db) else {
return Vec::new();
};
project_graph(db)
.host_modules
.get(&normalize_path(path))
.cloned()
.unwrap_or_default()
}
fn workspace_package_for(db: &dyn IncrementalDb, path: &Path) -> Option<Arc<PackageIndex>> {
let index = LibraryIndex::try_get(db)?;
let path = normalize_path(path);
let mut best: Option<(usize, &String)> = None;
for name in index.workspaces(db) {
let Some(root) = index.roots(db).0.get(name) else {
continue;
};
let src = normalize_path(&root.join("src"));
if !path.starts_with(&src) {
continue;
}
let depth = src.components().count();
if best.is_none_or(|(d, _)| depth > d) {
best = Some((depth, name));
}
}
let (_, name) = best?;
index.packages(db).0.get(name).cloned()
}
fn workspace_member_of(
db: &dyn IncrementalDb,
file: SourceFile,
) -> Option<(Arc<PackageIndex>, ModulePath)> {
let path = file.path(db).as_deref()?;
let pkg = workspace_package_for(db, path)?;
let host = host_module_of(db, file).iter().map(SmolStr::new).collect();
Some((pkg, host))
}
#[salsa::tracked(returns(ref))]
pub fn file_workspace_occurrences(
db: &dyn IncrementalDb,
file: SourceFile,
) -> WorkspaceOccurrences {
let model = semantic_model(db, file);
let packages = DbPackages(db);
let map = Resolver::new(model, &packages)
.with_workspace(workspace_member_of(db, file))
.workspace_occurrences();
WorkspaceOccurrences(map)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WorkspaceOccurrences(pub BTreeMap<OccurrenceKey, Vec<OccurrenceRec>>);
struct DbPackages<'a>(&'a dyn IncrementalDb);
impl PackageSource for DbPackages<'_> {
fn package(&self, name: &str) -> Option<Arc<PackageIndex>> {
LibraryIndex::try_get(self.0)?
.packages(self.0)
.0
.get(name)
.cloned()
}
fn package_root(&self, name: &str) -> Option<PathBuf> {
LibraryIndex::try_get(self.0)?
.roots(self.0)
.0
.get(name)
.cloned()
}
}
#[salsa::tracked(returns(ref))]
pub fn workspace_reference_index(db: &dyn IncrementalDb) -> WorkspaceReferenceIndex {
let mut out: BTreeMap<OccurrenceKey, Vec<(SourceFile, OccurrenceRec)>> = BTreeMap::new();
let Some(wf) = WorkspaceFiles::try_get(db) else {
return WorkspaceReferenceIndex::default();
};
for &file in wf.files(db) {
let projection = file_workspace_occurrences(db, file);
for (key, recs) in &projection.0 {
let bucket = out.entry(key.clone()).or_default();
bucket.extend(recs.iter().map(|rec| (file, *rec)));
}
}
WorkspaceReferenceIndex(out)
}
#[derive(Clone, Default, PartialEq, Eq)]
pub struct WorkspaceReferenceIndex(pub BTreeMap<OccurrenceKey, Vec<(SourceFile, OccurrenceRec)>>);
pub fn normalize_path(path: &Path) -> PathBuf {
use std::path::Component;
let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
let mut out = PathBuf::new();
for component in absolute.components() {
match component {
Component::CurDir => {}
Component::ParentDir
if matches!(out.components().next_back(), Some(Component::Normal(_))) =>
{
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
#[derive(Default)]
struct FileSourceMap {
by_path: HashMap<PathBuf, SourceFile>,
next_id: u32,
}
impl FileSourceMap {
fn alloc_id(&mut self) -> FileId {
let id = FileId(self.next_id);
self.next_id += 1;
id
}
}
#[salsa::db]
pub struct IncrementalDatabase {
storage: salsa::Storage<Self>,
source_map: Arc<Mutex<FileSourceMap>>,
}
impl Default for IncrementalDatabase {
fn default() -> Self {
Self {
storage: salsa::Storage::new(None),
source_map: Arc::new(Mutex::new(FileSourceMap::default())),
}
}
}
impl Clone for IncrementalDatabase {
fn clone(&self) -> Self {
Self {
storage: self.storage.clone(),
source_map: Arc::clone(&self.source_map),
}
}
}
impl std::fmt::Debug for IncrementalDatabase {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IncrementalDatabase")
.finish_non_exhaustive()
}
}
#[salsa::db]
impl salsa::Database for IncrementalDatabase {}
#[salsa::db]
impl IncrementalDb for IncrementalDatabase {}
impl IncrementalDatabase {
pub fn new() -> Self {
Self::default()
}
pub fn add_file(&self, text: impl Into<String>) -> SourceFile {
let id = self
.source_map
.lock()
.expect("file source map mutex poisoned")
.alloc_id();
SourceFile::new(self, id, None, text.into())
}
pub fn upsert_file(&mut self, path: &Path, text: String) -> SourceFile {
let key = normalize_path(path);
let existing = self
.source_map
.lock()
.expect("file source map mutex poisoned")
.by_path
.get(&key)
.copied();
match existing {
Some(file) => {
if file.text(self) != &text {
file.set_text(self).to(text);
}
file
}
None => {
let id = self
.source_map
.lock()
.expect("file source map mutex poisoned")
.alloc_id();
let file = SourceFile::new(self, id, Some(key.clone()), text);
self.source_map
.lock()
.expect("file source map mutex poisoned")
.by_path
.insert(key, file);
file
}
}
}
pub fn lookup_file(&self, path: &Path) -> Option<SourceFile> {
let key = normalize_path(path);
self.source_map
.lock()
.expect("file source map mutex poisoned")
.by_path
.get(&key)
.copied()
}
pub fn set_file_text(&mut self, file: SourceFile, text: impl Into<String>) {
file.set_text(self).to(text.into());
}
pub fn file_text(&self, file: SourceFile) -> &str {
file.text(self).as_str()
}
pub fn file_path(&self, file: SourceFile) -> Option<PathBuf> {
file.path(self).clone()
}
pub fn parsed_tree(&self, file: SourceFile) -> SyntaxNode {
parsed_tree_root(self, file)
}
pub fn set_library(
&mut self,
packages: BTreeMap<String, Arc<PackageIndex>>,
roots: BTreeMap<String, PathBuf>,
workspaces: Vec<String>,
) {
let packages = LibraryPackages(packages);
let roots = LibraryRoots(roots);
match LibraryIndex::try_get(self) {
Some(index) => {
index
.set_packages(self)
.with_durability(Durability::HIGH)
.to(packages);
index
.set_roots(self)
.with_durability(Durability::HIGH)
.to(roots);
index
.set_workspaces(self)
.with_durability(Durability::HIGH)
.to(workspaces);
}
None => {
let _ = LibraryIndex::builder(packages, roots, workspaces)
.durability(Durability::HIGH)
.new(self);
}
}
}
pub fn set_library_packages(&mut self, packages: BTreeMap<String, Arc<PackageIndex>>) {
let (roots, workspaces) = LibraryIndex::try_get(self)
.map(|lib| (lib.roots(self).0.clone(), lib.workspaces(self).clone()))
.unwrap_or_default();
self.set_library(packages, roots, workspaces);
}
pub fn set_package_index(&mut self, name: impl Into<String>, index: Arc<PackageIndex>) {
let mut packages = LibraryIndex::try_get(self)
.map(|lib| lib.packages(self).0.clone())
.unwrap_or_default();
packages.insert(name.into(), index);
self.set_library_packages(packages);
}
pub fn seed_disk_file(&mut self, path: &Path) -> Option<SourceFile> {
let key = normalize_path(path);
if let Some(file) = self.lookup_file(&key) {
return Some(file);
}
let text = std::fs::read_to_string(&key).ok()?;
let id = self
.source_map
.lock()
.expect("file source map mutex poisoned")
.alloc_id();
let file = SourceFile::new(self, id, Some(key.clone()), text);
self.source_map
.lock()
.expect("file source map mutex poisoned")
.by_path
.insert(key, file);
Some(file)
}
pub fn set_workspace_files(&mut self, files: Vec<SourceFile>) {
match WorkspaceFiles::try_get(self) {
Some(wf) => {
wf.set_files(self).to(files);
}
None => {
let _ = WorkspaceFiles::builder(files).new(self);
}
}
}
pub fn revert_file_to_disk(&mut self, path: &Path) {
let Some(file) = self.lookup_file(path) else {
return;
};
let Ok(text) = std::fs::read_to_string(path) else {
return;
};
if file.text(self) != &text {
file.set_text(self).to(text);
}
}
pub fn seed_workspace_members(&mut self) {
let names = self.workspace_packages();
if names.is_empty() {
return;
}
let mut files: Vec<SourceFile> = Vec::new();
let mut seen: std::collections::HashSet<SourceFile> = std::collections::HashSet::new();
for name in names {
let (Some(pkg), Some(root)) = (self.library_package(&name), self.package_root(&name))
else {
continue;
};
for rel in &pkg.members {
if let Some(file) = self.seed_disk_file(&root.join(rel))
&& seen.insert(file)
{
files.push(file);
}
}
}
self.set_workspace_files(files);
}
pub fn library_package(&self, name: &str) -> Option<Arc<PackageIndex>> {
LibraryIndex::try_get(self)?
.packages(self)
.0
.get(name)
.cloned()
}
pub fn package_root(&self, name: &str) -> Option<PathBuf> {
LibraryIndex::try_get(self)?
.roots(self)
.0
.get(name)
.cloned()
}
pub fn workspace_packages(&self) -> Vec<String> {
LibraryIndex::try_get(self)
.map(|lib| lib.workspaces(self).clone())
.unwrap_or_default()
}
pub fn workspace_member(&self, path: &Path) -> Option<(Arc<PackageIndex>, ModulePath)> {
let pkg = workspace_package_for(self, path)?;
let host = self
.lookup_file(path)
.map(|file| {
host_module_of(self, file)
.iter()
.map(SmolStr::new)
.collect()
})
.unwrap_or_default();
Some((pkg, host))
}
pub fn workspace_module(&self, path: &Path) -> Option<Arc<PackageIndex>> {
self.workspace_member(path).map(|(pkg, _)| pkg)
}
pub fn snapshot(&self) -> Analysis {
Analysis(self.clone())
}
}
pub struct Analysis(IncrementalDatabase);
impl Analysis {
pub fn lookup_file(&self, path: &Path) -> Option<SourceFile> {
self.0.lookup_file(path)
}
pub fn file_text(&self, file: SourceFile) -> &str {
self.0.file_text(file)
}
pub fn parse_diagnostics(&self, file: SourceFile) -> &[ParseDiagnostic] {
parse_diagnostics(&self.0, file)
}
pub fn parsed_tree(&self, file: SourceFile) -> SyntaxNode {
self.0.parsed_tree(file)
}
pub fn semantic_model(&self, file: SourceFile) -> &SemanticModel {
semantic_model(&self.0, file)
}
pub fn file_exports(&self, file: SourceFile) -> &BTreeSet<String> {
file_exports(&self.0, file)
}
pub fn file_free_reads(&self, file: SourceFile) -> &BTreeSet<String> {
file_free_reads(&self.0, file)
}
pub fn file_qualified_reads(&self, file: SourceFile) -> &BTreeSet<String> {
file_qualified_reads(&self.0, file)
}
pub fn include_edges(&self, file: SourceFile) -> &[IncludeEdge] {
include_edges(&self.0, file)
}
pub fn workspace_reference_index(&self) -> &WorkspaceReferenceIndex {
workspace_reference_index(&self.0)
}
pub fn project_graph(&self) -> &ProjectGraph {
project_graph(&self.0)
}
pub fn file_path_of(&self, file: SourceFile) -> Option<PathBuf> {
self.0.file_path(file)
}
pub fn file_text_of(&self, file: SourceFile) -> &str {
self.0.file_text(file)
}
pub fn library_package(&self, name: &str) -> Option<Arc<PackageIndex>> {
self.0.library_package(name)
}
pub fn package_root(&self, name: &str) -> Option<PathBuf> {
self.0.package_root(name)
}
pub fn workspace_packages(&self) -> Vec<String> {
self.0.workspace_packages()
}
pub fn workspace_member(&self, path: &Path) -> Option<(Arc<PackageIndex>, ModulePath)> {
self.0.workspace_member(path)
}
pub fn workspace_module(&self, path: &Path) -> Option<Arc<PackageIndex>> {
self.0.workspace_module(path)
}
pub fn resolve_name(
&self,
file: SourceFile,
name: &str,
offset: TextSize,
namespace: Namespace,
) -> Resolution {
Resolver::new(self.semantic_model(file), self)
.with_workspace(self.workspace_module_for(file))
.resolve(name, offset, namespace)
}
pub fn visible_names(
&self,
file: SourceFile,
offset: TextSize,
namespace: Namespace,
) -> Vec<Candidate> {
Resolver::new(self.semantic_model(file), self)
.with_workspace(self.workspace_module_for(file))
.visible(offset, namespace)
}
fn workspace_module_for(&self, file: SourceFile) -> Option<(Arc<PackageIndex>, ModulePath)> {
let path = self.0.file_path(file)?;
self.workspace_member(&path)
}
}
impl PackageSource for Analysis {
fn package(&self, name: &str) -> Option<Arc<PackageIndex>> {
self.library_package(name)
}
fn package_root(&self, name: &str) -> Option<PathBuf> {
Analysis::package_root(self, name)
}
fn workspace_member(&self, path: &Path) -> Option<(Arc<PackageIndex>, ModulePath)> {
Analysis::workspace_member(self, path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_and_reparses_on_edit() {
let mut db = IncrementalDatabase::new();
let file = db.add_file("x = 1\n");
assert_eq!(parsed_tree_root(&db, file).to_string(), "x = 1\n");
db.set_file_text(file, "x = 2 + 3\n");
let root = parsed_tree_root(&db, file);
assert_eq!(root.to_string(), "x = 2 + 3\n");
assert!(parse_diagnostics(&db, file).is_empty());
}
#[test]
fn upsert_dedups_by_normalized_path() {
let mut db = IncrementalDatabase::new();
let a = db.upsert_file(Path::new("/tmp/a.jl"), "x = 1\n".into());
let b = db.upsert_file(Path::new("/tmp/./a.jl"), "x = 2\n".into());
assert!(a == b, "equivalent path spellings should reuse one input");
assert_eq!(parsed_tree_root(&db, a).to_string(), "x = 2\n");
}
}