use blends_domain::ast::AstGraph;
use blends_domain::syntax;
use blends_domain::syntax::cfg;
use blends_domain::syntax::cfg::SyntaxCfgError;
use blends_domain::syntax::SyntaxGraph;
use blends_domain::syntax::SyntaxNode;
use crate::content::Content;
fn trace_missing_readers(syntax_graph: &SyntaxGraph, content: &Content) {
for (n_id, node) in &syntax_graph.nodes {
if let SyntaxNode::MissingNode { node_type } = node {
tracing::debug!(
node_type,
language = ?content.language,
node_id = n_id.0,
path = %content.path.display(),
"missing syntax reader"
);
}
}
}
fn apply_cfg_overlay(
syntax_graph: &mut SyntaxGraph,
content: &Content,
is_multifile: bool,
) -> bool {
match cfg::add_syntax_cfg(syntax_graph, is_multifile) {
Ok(()) => true,
Err(error @ SyntaxCfgError::MissingCfgBuilder { .. }) => {
tracing::error!(
%error,
path = %content.path.display(),
"missing cfg builder"
);
true
}
Err(error) => {
tracing::error!(
%error,
path = %content.path.display(),
"unable to build the syntax_graph"
);
false
}
}
}
#[must_use]
pub fn get_syntax_graph(
ast_graph: &AstGraph,
content: &Content,
with_cfg: Option<bool>,
with_metadata: Option<bool>,
) -> Option<SyntaxGraph> {
let path = content.path.display().to_string();
let with_metadata = with_metadata.unwrap_or(false);
let mut syntax_graph =
match syntax::build_syntax_graph(ast_graph, &path, content.language, with_metadata) {
Ok(syntax_graph) => syntax_graph,
Err(syntax::SyntaxGraphError::UnsupportedLanguage) => {
tracing::debug!(
language = ?content.language,
path = %content.path.display(),
"syntax dispatcher not implemented yet"
);
return None;
}
Err(error) => {
tracing::error!(
%error,
path = %content.path.display(),
"unable to build the syntax_graph"
);
return None;
}
};
trace_missing_readers(&syntax_graph, content);
if with_cfg.unwrap_or(true) && !apply_cfg_overlay(&mut syntax_graph, content, with_metadata) {
return None;
}
syntax_graph.finalize_symbol_index();
Some(syntax_graph)
}
#[cfg(test)]
mod tests {
use super::get_syntax_graph;
use crate::ast::get_ast_graph;
use crate::content::Content;
use crate::language::Language;
use blends_domain::ast::{AstGraph, AstNode};
use blends_domain::query::get_all_scope_definitions;
use blends_domain::stack::{
build_stack_graph, import_sites, BindingReach, StackGraph, StackNode,
};
use blends_domain::syntax::{SyntaxGraph, SyntaxNode};
use blends_domain::NodeId;
use std::path::PathBuf;
use test_case::test_case;
type SiteRow = (
NodeId,
String,
String,
Option<String>,
u32,
bool,
BindingReach,
);
fn site_rows(graph: &SyntaxGraph, language: Language) -> Vec<SiteRow> {
import_sites(graph, language)
.into_iter()
.map(|site| {
let binding = site.binding;
(
site.scope,
site.symbol,
binding.module_stem,
binding.source_symbol,
binding.relative_level,
binding.binds_module,
site.reach,
)
})
.collect()
}
fn site_row(
symbol: &str,
module_stem: &str,
relative_level: u32,
source_symbol: Option<&str>,
binds_module: bool,
) -> SiteRow {
(
NodeId(1),
symbol.to_owned(),
module_stem.to_owned(),
source_symbol.map(str::to_owned),
relative_level,
binds_module,
BindingReach::Scope,
)
}
fn content_for(language: Language, extension: &str, text: &str) -> Content {
Content {
bytes: text.as_bytes().to_vec(),
text: text.to_owned(),
language,
path: PathBuf::from(format!("test.{extension}")),
}
}
fn single_node_ast(node_type: &str) -> AstGraph {
let mut ast = AstGraph::new();
ast.add_node(NodeId(1), AstNode::new(1, 1, node_type.to_owned()));
ast
}
#[test]
fn builds_the_syntax_graph_for_a_supported_language() {
let content = content_for(Language::Yaml, "yaml", "key: value");
let ast = single_node_ast("stream");
let result = get_syntax_graph(&ast, &content, None, None);
let graph = result.expect("syntax graph should be built");
assert!(!graph.nodes.is_empty());
}
fn graph_for(language: Language, extension: &str, source: &str) -> SyntaxGraph {
let content = content_for(language, extension, source);
let ast = get_ast_graph(&content).expect("source should parse");
get_syntax_graph(&ast, &content, None, None).expect("graph should be built")
}
#[test_case("import a.b\n", "a" ; "a dotted name defines its top level package")]
#[test_case("import a.b as c\n", "c" ; "an aliased dotted name defines the alias")]
fn a_plain_python_import_defines_the_name_it_binds(source: &str, bound: &str) {
let graph = graph_for(Language::Python, "py", source);
assert_eq!(
graph
.symbol_index
.keys()
.map(String::as_str)
.collect::<Vec<_>>(),
[bound]
);
}
#[test_case("import os\n", &[("os", "os")] ; "a single name binds the module of that name")]
#[test_case("import a.b\n", &[("a", "a")] ; "a dotted name binds its top level package")]
#[test_case("import a.b, a.c\n", &[("a", "a"), ("a", "a")] ; "two submodules of one package both bind the package")]
#[test_case("import a.b as c\n", &[("c", "a.b")] ; "an alias binds the full dotted module")]
#[test_case("import a.py as c\n", &[("c", "a.py")] ; "a submodule named like a file extension stays in the module path")]
#[test_case("import os, sys as system\n", &[("os", "os"), ("system", "sys")] ; "every name of a multi name import binds its own module")]
fn plain_python_imports_bind_modules(source: &str, expected: &[(&str, &str)]) {
let graph = graph_for(Language::Python, "py", source);
let expected: Vec<_> = expected
.iter()
.map(|(symbol, module_stem)| site_row(symbol, module_stem, 0, None, true))
.collect();
assert_eq!(site_rows(&graph, Language::Python), expected);
}
#[test_case("from x import y\n", &[("y", "x", 0, None)] ; "a from import binds the name of the module symbol")]
#[test_case("from x import x\n", &[("x", "x", 0, None)] ; "a symbol named like its module binds that symbol")]
#[test_case("from x import y as z\n", &[("z", "x", 0, Some("y"))] ; "an alias binds the module symbol under the alias")]
#[test_case("from a.b import y\n", &[("y", "a.b", 0, None)] ; "a dotted module keeps every segment")]
#[test_case("from x import (a, b as c)\n", &[("a", "x", 0, None), ("c", "x", 0, Some("b"))] ; "every name of a parenthesized list is bound")]
#[test_case("from ..x import y\n", &[("y", "x", 2, None)] ; "each leading dot climbs one package level")]
#[test_case("from . import y\n", &[("y", "", 1, None)] ; "a bare dot imports from the current package")]
#[test_case("from .. import q as r\n", &[("r", "", 2, Some("q"))] ; "an aliased name from a parent package keeps its source name")]
#[test_case("from x import *\n", &[("*", "x", 0, None)] ; "a wildcard binds every public name of the module")]
#[test_case("from .m import *\n", &[("*", "m", 1, None)] ; "a relative wildcard keeps its package level")]
fn python_from_imports_bind_module_symbols(
source: &str,
expected: &[(&str, &str, u32, Option<&str>)],
) {
let graph = graph_for(Language::Python, "py", source);
let expected: Vec<_> = expected
.iter()
.map(|(symbol, module_stem, relative_level, source_symbol)| {
site_row(symbol, module_stem, *relative_level, *source_symbol, false)
})
.collect();
assert_eq!(site_rows(&graph, Language::Python), expected);
}
#[test_case("js", "import * as ns from \"m\";\n", Some("namespace_import") ; "a namespace import is marked as such")]
#[test_case("ts", "import x = require(\"m\");\n", Some("import_require_clause") ; "an import require is marked as such")]
#[test_case("js", "import d from \"m\";\n", None ; "a default import carries no import type")]
fn script_import_nodes_record_their_clause(
extension: &str,
source: &str,
expected: Option<&str>,
) {
let language = if extension == "ts" {
Language::TypeScript
} else {
Language::JavaScript
};
let graph = graph_for(language, extension, source);
let import_types: Vec<_> = graph
.nodes
.values()
.filter_map(|node| match node {
SyntaxNode::Import { import_type, .. } => Some(import_type.as_deref()),
_ => None,
})
.collect();
assert_eq!(import_types, [expected]);
}
#[test_case("js", "import d from \"./m.js\";\n", &[("d", "m", 1, Some("default"), false)] ; "a default import binds the default export")]
#[test_case("js", "import { a } from \"lib/x\";\n", &[("a", "lib.x", 0, None, false)] ; "a named import binds the export of that name")]
#[test_case("js", "import { b as c } from \"lib/x\";\n", &[("c", "lib.x", 0, Some("b"), false)] ; "a renamed import binds the export under the new name")]
#[test_case("js", "import { default as d } from \"m\";\n", &[("d", "m", 0, Some("default"), false)] ; "a default renamed in braces binds the default export")]
#[test_case("js", "import d, { e } from \"m\";\n", &[("d", "m", 0, Some("default"), false), ("e", "m", 0, None, false)] ; "a default and a named import in one statement both bind")]
#[test_case("js", "import * as ns from \"../pkg/ns\";\n", &[("ns", "pkg.ns", 2, None, true)] ; "a namespace import binds the whole module")]
#[test_case("js", "import x from \"../../a/b\";\n", &[("x", "a.b", 3, Some("default"), false)] ; "each parent directory climbs one more level")]
#[test_case("js", "import x from \"..\";\n", &[("x", "", 2, Some("default"), false)] ; "a bare parent directory imports its index module")]
#[test_case("js", "import \"side\";\n", &[] ; "a side effect import binds nothing")]
#[test_case("ts", "import type { T } from \"./types\";\n", &[("T", "types", 1, None, false)] ; "a type only import binds the export of that name")]
#[test_case("ts", "import x = require(\"old\");\n", &[("x", "old", 0, None, true)] ; "an import require binds the whole module")]
#[test_case("js", "const svc = require(\"../svc\");\n", &[("svc", "svc", 2, None, true)] ; "a required module binds the whole module")]
#[test_case("js", "const { alpha } = require(\"./lib\");\n", &[("alpha", "lib", 1, None, false)] ; "a destructured name binds the export of that name")]
#[test_case("js", "const { beta: b } = require(\"lib/x\");\n", &[("b", "lib.x", 0, Some("beta"), false)] ; "a renamed destructured name binds the export under the new name")]
#[test_case("js", "const { a, b: c } = require(\"m\");\n", &[("a", "m", 0, None, false), ("c", "m", 0, Some("b"), false)] ; "every name of a destructured require is bound")]
#[test_case("ts", "const svc = require(\"./svc\");\n", &[("svc", "svc", 1, None, true)] ; "a typescript require binds the whole module")]
#[test_case("js", "const p = require(name);\n", &[] ; "a computed module binds nothing")]
#[test_case("js", "const r = load(\"m\");\n", &[] ; "a call to another function binds nothing")]
fn script_imports_bind_module_exports(
extension: &str,
source: &str,
expected: &[(&str, &str, u32, Option<&str>, bool)],
) {
let language = if extension == "ts" {
Language::TypeScript
} else {
Language::JavaScript
};
let graph = graph_for(language, extension, source);
let expected: Vec<_> = expected
.iter()
.map(
|(symbol, module_stem, relative_level, source_symbol, binds_module)| {
site_row(
symbol,
module_stem,
*relative_level,
*source_symbol,
*binds_module,
)
},
)
.collect();
assert_eq!(site_rows(&graph, language), expected);
}
#[test_case("js", "export { a } from \"./m\";\n", &[("a", "m", 1, None, false)] ; "a named re-export exports the module export of that name")]
#[test_case("js", "export { b as c } from \"lib/x\";\n", &[("c", "lib.x", 0, Some("b"), false)] ; "a renamed re-export exports the module export under the new name")]
#[test_case("js", "export { default } from \"../m\";\n", &[("default", "m", 2, None, false)] ; "a re-exported default stays the default export")]
#[test_case("js", "export { e as default } from \"m\";\n", &[("default", "m", 0, Some("e"), false)] ; "a named export re-exported as default becomes the default export")]
#[test_case("js", "export { default as d } from \"m\";\n", &[("d", "m", 0, Some("default"), false)] ; "a default re-exported under a name exports that name")]
#[test_case("js", "export { a, b as c } from \"m\";\n", &[("a", "m", 0, None, false), ("c", "m", 0, Some("b"), false)] ; "every name of a re-export clause is exported")]
#[test_case("js", "export * as ns from \"./pkg/ns\";\n", &[("ns", "pkg.ns", 1, None, true)] ; "a namespace re-export exports the whole module")]
#[test_case("ts", "export type { T } from \"./types\";\n", &[("T", "types", 1, None, false)] ; "a type only re-export exports the module export of that name")]
#[test_case("js", "const a = 1;\nexport { a };\n", &[] ; "an export of a local name binds no module")]
#[test_case("js", "export { \"a-b\" as c } from \"m\";\n", &[] ; "a string named re-export binds nothing while its name is unread")]
#[test_case("js", "export * from \"./m\";\n", &[("*", "m", 1, None, false)] ; "a star re-export exports every name of the module")]
#[test_case("ts", "export * from \"lib/x\";\n", &[("*", "lib.x", 0, None, false)] ; "a typescript star re-export exports every name of the module")]
#[test_case("ts", "export = foo;\n", &[] ; "an export assignment binds no module")]
#[test_case("ts", "export as namespace Foo;\n", &[] ; "a global namespace export binds no module")]
#[test_case("ts", "declare module \"x\" { export { a } from \"./b\"; }\n", &[] ; "a re-export inside an ambient module is not exported by the file")]
fn script_reexports_bind_module_exports_for_importers(
extension: &str,
source: &str,
expected: &[(&str, &str, u32, Option<&str>, bool)],
) {
let language = if extension == "ts" {
Language::TypeScript
} else {
Language::JavaScript
};
let graph = graph_for(language, extension, source);
let expected: Vec<_> = expected
.iter()
.map(
|(symbol, module_stem, relative_level, source_symbol, binds_module)| {
let (
scope,
symbol,
module_stem,
source_symbol,
relative_level,
binds_module,
_,
) = site_row(
symbol,
module_stem,
*relative_level,
*source_symbol,
*binds_module,
);
(
scope,
symbol,
module_stem,
source_symbol,
relative_level,
binds_module,
BindingReach::Importers,
)
},
)
.collect();
assert_eq!(site_rows(&graph, language), expected);
}
#[test_case("js", "export * from \"m\";\n", Some("*") ; "a star re-export exports under the wildcard alias")]
#[test_case("js", "export * as ns from \"m\";\n", Some("ns") ; "a namespace re-export exports under its namespace")]
#[test_case("ts", "export = foo;\n", None ; "an export assignment carries no alias")]
fn script_export_statements_record_their_exported_alias(
extension: &str,
source: &str,
expected: Option<&str>,
) {
let language = if extension == "ts" {
Language::TypeScript
} else {
Language::JavaScript
};
let graph = graph_for(language, extension, source);
let aliases: Vec<_> = graph
.nodes
.values()
.filter_map(|node| match node {
SyntaxNode::Export { alias, .. } => Some(alias.as_deref()),
_ => None,
})
.collect();
assert_eq!(aliases, [expected]);
}
#[test]
fn an_import_beside_a_reexport_stays_visible_in_its_scope() {
let graph = graph_for(
Language::JavaScript,
"js",
"import { a } from \"m\";\nexport { a } from \"m\";\n",
);
assert_eq!(
site_rows(&graph, Language::JavaScript)
.into_iter()
.map(|row| (row.1, row.6))
.collect::<Vec<_>>(),
[
("a".to_owned(), BindingReach::Scope),
("a".to_owned(), BindingReach::Importers)
]
);
}
#[test_case("def f():\n import os\n" ; "a single name")]
#[test_case("def f():\n import a.b\n" ; "a dotted name")]
fn an_import_site_keeps_its_import_node_and_innermost_scope(source: &str) {
let graph = graph_for(Language::Python, "py", source);
let first_of = |is_wanted: fn(&SyntaxNode) -> bool| {
graph
.nodes
.iter()
.find_map(|(n_id, node)| is_wanted(node).then_some(*n_id))
};
let function = first_of(|node| matches!(node, SyntaxNode::MethodDeclaration { .. }));
let import = first_of(|node| matches!(node, SyntaxNode::Import { .. }));
assert_eq!(
import_sites(&graph, Language::Python)
.iter()
.map(|site| (Some(site.node), Some(site.scope)))
.collect::<Vec<_>>(),
[(import, function)]
);
}
#[test]
fn imports_of_other_languages_are_not_read_yet() {
let graph = graph_for(Language::Java, "java", "import a.b.C;\nclass T {}\n");
assert!(import_sites(&graph, Language::Java).is_empty());
}
fn lookup_for(graph: &blends_domain::syntax::SyntaxGraph, wanted: &str) -> NodeId {
graph
.nodes
.iter()
.find_map(|(n_id, node)| match node {
SyntaxNode::SymbolLookup { symbol, .. } if symbol == wanted => Some(*n_id),
_ => None,
})
.expect("lookup should exist")
}
#[test]
fn scope_definitions_resolve_the_second_local_declarator() {
let content = content_for(
Language::Java,
"java",
"class T { void run() { String car = \"a\", plane = \"b\", boat = \"c\"; System.out.println(plane); } }",
);
let ast = get_ast_graph(&content).expect("java source should parse");
let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
let definitions = get_all_scope_definitions(&graph, lookup_for(&graph, "plane"));
let &[def_id] = definitions.as_slice() else {
panic!("expected one scope definition for plane");
};
assert!(matches!(
graph.nodes.get(&def_id),
Some(SyntaxNode::VariableDeclaration { variable, variable_type: Some(variable_type), .. })
if variable == "plane" && variable_type == "String"
));
}
#[test]
fn scope_definitions_resolve_the_second_interface_constant() {
let content = content_for(
Language::Java,
"java",
"interface Limits { int MIN = 1, MAX = 10; default int clamp(int value) { return value > MAX ? MAX : value; } }",
);
let ast = get_ast_graph(&content).expect("java source should parse");
let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
let definitions = get_all_scope_definitions(&graph, lookup_for(&graph, "MAX"));
let &[def_id] = definitions.as_slice() else {
panic!("expected one scope definition for MAX");
};
assert!(matches!(
graph.nodes.get(&def_id),
Some(SyntaxNode::VariableDeclaration { variable, variable_type: Some(variable_type), .. })
if variable == "MAX" && variable_type == "int"
));
}
#[test]
fn metadata_node_added_at_zero_when_requested() {
let content = content_for(Language::Yaml, "yaml", "key: value");
let ast = single_node_ast("stream");
let result = get_syntax_graph(&ast, &content, None, Some(true));
let graph = result.expect("syntax graph should be built");
assert_eq!(
graph.nodes.get(&NodeId(0)),
Some(&SyntaxNode::Metadata {
path: "test.yaml".to_owned(),
structure: std::collections::BTreeMap::new(),
instances: std::collections::BTreeMap::new(),
imports: std::vec::Vec::new(),
package: None,
})
);
}
#[test_case(None ; "by default")]
#[test_case(Some(false) ; "when explicitly disabled")]
fn no_metadata_node(with_metadata: Option<bool>) {
let content = content_for(Language::Yaml, "yaml", "key: value");
let ast = single_node_ast("stream");
let result = get_syntax_graph(&ast, &content, None, with_metadata);
let graph = result.expect("syntax graph should be built");
assert!(!graph.nodes.contains_key(&NodeId(0)));
}
#[test]
fn language_without_dispatcher_has_no_syntax_graph() {
let content = content_for(Language::Elixir, "ex", "defmodule A do end");
let ast = single_node_ast("program");
assert!(get_syntax_graph(&ast, &content, None, None).is_none());
}
#[test]
fn unhandled_root_node_type_degrades_to_missing_node_and_recurses() {
let content = content_for(Language::Yaml, "yaml", "key: value");
let mut ast = single_node_ast("unknown_node_type_xyz");
ast.add_node(NodeId(2), AstNode::new(1, 5, "another_unknown".to_owned()));
ast.add_edge(NodeId(1), NodeId(2), 0);
let result = get_syntax_graph(&ast, &content, None, None);
let graph = result.expect("syntax graph should be built");
assert_eq!(
graph.nodes.get(&NodeId(1)),
Some(&SyntaxNode::MissingNode {
node_type: "unknown_node_type_xyz".to_owned()
})
);
assert_eq!(
graph.nodes.get(&NodeId(2)),
Some(&SyntaxNode::MissingNode {
node_type: "another_unknown".to_owned()
})
);
assert!(graph
.edges
.get(&NodeId(1))
.is_some_and(|adjacent| adjacent.contains_key(&NodeId(2))));
}
#[test]
fn fatal_reader_error_returns_none() {
let content = content_for(Language::Yaml, "yaml", "[]");
let ast = single_node_ast("flow_node");
assert!(get_syntax_graph(&ast, &content, None, None).is_none());
}
#[test]
fn empty_ast_graph_returns_none() {
let content = content_for(Language::Yaml, "yaml", "");
assert!(get_syntax_graph(&AstGraph::new(), &content, None, None).is_none());
}
#[test]
fn ast_graph_without_root_node_returns_none() {
let content = content_for(Language::Yaml, "yaml", "key: value");
let mut ast = AstGraph::new();
ast.add_node(NodeId(7), AstNode::new(1, 1, "stream".to_owned()));
assert!(get_syntax_graph(&ast, &content, None, None).is_none());
}
#[test]
fn cfg_marks_overlay_the_ast_edges_by_default() {
let content = content_for(Language::Yaml, "yaml", "key: value");
let ast = get_ast_graph(&content).expect("yaml should parse");
let result = get_syntax_graph(&ast, &content, None, None);
let graph = result.expect("syntax graph should be built");
assert!(graph
.edges
.get(&NodeId(1))
.is_some_and(|adjacent| adjacent.values().any(|edge| edge.cfg.is_some())));
}
#[test]
fn no_cfg_marks_when_disabled() {
let content = content_for(Language::Yaml, "yaml", "key: value");
let ast = get_ast_graph(&content).expect("yaml should parse");
let result = get_syntax_graph(&ast, &content, Some(false), None);
let graph = result.expect("syntax graph should be built");
assert!(graph
.edges
.values()
.flat_map(|adjacent| adjacent.values())
.all(|edge| edge.cfg.is_none()));
}
#[test]
fn empty_yaml_builds_a_lone_file_node() {
let content = content_for(Language::Yaml, "yaml", "");
let ast = get_ast_graph(&content).expect("empty yaml should parse");
let result = get_syntax_graph(&ast, &content, None, None);
let graph = result.expect("syntax graph should be built");
assert_eq!(graph.nodes.len(), 1);
assert_eq!(graph.nodes.get(&NodeId(1)), Some(&SyntaxNode::File));
}
#[test_case(Language::Python, "py", "for i in items:\n pass\n", &["items"] ; "a python for target is not a reference")]
#[test_case(Language::Python, "py", "r = [y for y in ys]\n", &["y", "ys"] ; "a comprehension variable is not a reference")]
#[test_case(Language::Python, "py", "def f(a, b=dflt, *rest):\n pass\n", &["dflt"] ; "a parameter is not a reference but its default is")]
#[test_case(Language::Python, "py", "a, *b = pair\n", &["pair"] ; "an unpacking target is not a reference")]
#[test_case(Language::Python, "py", "with open(p) as h:\n pass\n", &["open", "p"] ; "a with target is not a reference")]
#[test_case(Language::Python, "py", "x += 1\n", &["x"] ; "an augmented assignment target is a reference")]
#[test_case(Language::JavaScript, "js", "for (const x of xs) {}\n", &["xs"] ; "a javascript loop variable is not a reference")]
#[test_case(Language::JavaScript, "js", "y = z;\n", &["y", "z"] ; "a javascript assignment target is a reference")]
#[test_case(Language::JavaScript, "js", "const { a: b } = load();\n", &["load"] ; "a destructured name is not a reference")]
#[test_case(Language::JavaScript, "js", "try {} catch (e) {}\n", &[] ; "a catch parameter is not a reference")]
fn only_lookups_that_read_a_name_become_references(
language: Language,
extension: &str,
source: &str,
expected: &[&str],
) {
let syntax = graph_for(language, extension, source);
let stack = build_stack_graph(&syntax, language, "mod");
let references: Vec<_> = stack
.nodes
.values()
.filter(|node| node.is_reference())
.filter_map(StackNode::symbol)
.collect();
assert_eq!(references, expected);
}
fn path_to_root(stack: &StackGraph, start: NodeId) -> Option<Vec<&str>> {
let mut pushed = Vec::new();
let mut current = start;
loop {
match stack.nodes.get(¤t)? {
StackNode::Root => {
pushed.reverse();
return Some(pushed);
}
StackNode::PushSymbol(_) => pushed.extend(stack.nodes.get(¤t)?.symbol()),
_ => {}
}
current = match stack.successors(current).as_slice() {
[(next, _)] => *next,
_ => return None,
};
}
}
type ImportPath<'a> = (Option<&'a str>, Option<Vec<&'a str>>);
fn import_paths(stack: &StackGraph) -> Vec<ImportPath<'_>> {
stack
.nodes
.iter()
.filter(|(_, node)| matches!(node, StackNode::Scope(_)))
.flat_map(|(scope, _)| stack.successors(*scope))
.filter_map(|(start, _)| match stack.nodes.get(&start)? {
StackNode::PushSymbol(_) => Some((None, path_to_root(stack, start))),
node if node.import_binding().is_some() => {
let next = stack.successors(start).first().map(|(next, _)| *next);
Some((
node.symbol(),
next.and_then(|next| path_to_root(stack, next)),
))
}
_ => None,
})
.collect()
}
#[test_case(Language::Python, "pkg/sub/mod.py", "import os\n", &[(Some("os"), Some(&["os"]))] ; "a plain import reaches the module it names")]
#[test_case(Language::Python, "pkg/sub/mod.py", "import a.b\n", &[(Some("a"), Some(&["a"]))] ; "a dotted import reaches its top level package")]
#[test_case(Language::Python, "pkg/sub/mod.py", "import a.b as c\n", &[(Some("c"), Some(&["a", ".", "b"]))] ; "an aliased dotted import reaches the full module")]
#[test_case(Language::Python, "pkg/sub/mod.py", "from a.b import y\n", &[(Some("y"), Some(&["a", ".", "b", "y"]))] ; "a from import reaches the symbol inside its module")]
#[test_case(Language::Python, "pkg/sub/mod.py", "from x import y as z\n", &[(Some("z"), Some(&["x", "y"]))] ; "an aliased from import reaches the source symbol")]
#[test_case(Language::Python, "pkg/sub/mod.py", "from . import y\n", &[(Some("y"), Some(&["pkg", ".", "sub", "y"]))] ; "a bare dot reaches the importing package")]
#[test_case(Language::Python, "pkg/sub/mod.py", "from ..m import y\n", &[(Some("y"), Some(&["pkg", ".", "m", "y"]))] ; "a second dot reaches a sibling of the importing package")]
#[test_case(Language::Python, "pkg/sub/mod.py", "from ... import y\n", &[(Some("y"), None)] ; "a relative import above the top package binds without reaching a module")]
#[test_case(Language::Python, "pkg/sub/mod.py", "def f():\n import os\n", &[(Some("os"), Some(&["os"]))] ; "an import inside a function binds in the function scope")]
#[test_case(Language::Python, "pkg/sub/mod.py", "from x import *\n", &[(None, Some(&["x"]))] ; "a wildcard lets lookups continue into its module")]
#[test_case(Language::JavaScript, "src/app.js", "import d from \"./m\";\n", &[(Some("d"), Some(&["src", ".", "m", "default"]))] ; "a default import reaches the default export")]
#[test_case(Language::JavaScript, "src/app.js", "import * as ns from \"m\";\n", &[(Some("ns"), Some(&["m"]))] ; "a namespace import reaches the module")]
#[test_case(Language::JavaScript, "src/app.js", "const { a: b } = require(\"../lib\");\n", &[(Some("b"), Some(&["lib", "a"]))] ; "a destructured require reaches the source export")]
#[test_case(Language::JavaScript, "src/app.js", "import d from \"../m\";\n", &[(Some("d"), Some(&["m", "default"]))] ; "a script relative import may reach the top directory")]
#[test_case(Language::JavaScript, "src/app.js", "import d from \"../../m\";\n", &[(Some("d"), None)] ; "a script relative import above the top directory binds without reaching a module")]
#[test_case(Language::JavaScript, "src/app.js", "export { a } from \"./m\";\n", &[] ; "a re-export binds nothing for the code of its file")]
fn imports_reach_the_root_with_their_qualified_name(
language: Language,
path: &str,
source: &str,
expected: &[(Option<&str>, Option<&[&str]>)],
) {
let extension = path.rsplit_once('.').map_or("", |(_, extension)| extension);
let syntax = graph_for(language, extension, source);
let stack = build_stack_graph(&syntax, language, path);
let expected: Vec<_> = expected
.iter()
.map(|(bound, pushed)| (*bound, pushed.map(<[&str]>::to_vec)))
.collect();
assert_eq!(import_paths(&stack), expected);
}
#[test_case(Language::Python, "py", "from m import a\n", false ; "a python import stays visible to importers of the module")]
#[test_case(Language::JavaScript, "js", "import { a } from \"m\";\n", true ; "a javascript import is private to its module")]
fn an_import_binding_is_private_only_where_imports_stay_in_the_module(
language: Language,
extension: &str,
source: &str,
expected: bool,
) {
let stack = build_stack_graph(&graph_for(language, extension, source), language, "mod");
let privacy: Vec<_> = stack
.nodes
.values()
.filter_map(|node| match node {
StackNode::PopSymbol(pop) if pop.import.is_some() => pop
.definition
.as_ref()
.map(|definition| definition.is_private),
_ => None,
})
.collect();
assert_eq!(privacy, [expected]);
}
}