Skip to main content

command_stream/fy/
formalizer.rs

1//! Formalizes a shell script as a meta-language links network.
2//!
3//! This is the first half of the translation pipeline the way
4//! link-foundation/meta-language models it: source text becomes a network of
5//! typed links (`LinkType::Syntax` nodes over `LinkType::Token` leaves). The
6//! second half — [`super::rules`] plus [`super::engine`] — rewrites that
7//! network into JavaScript purely by substitution rules.
8
9use std::collections::HashSet;
10
11use meta_language::language_profile::LanguageProfile;
12use meta_language::link_network::{LinkId, LinkNetwork, LinkType};
13
14use super::parser::{parse_shell_script, Node};
15use super::word_expander::expand_word;
16
17/// The language name used for every shell link in the network.
18pub const SHELL_LANGUAGE: &str = "Shell";
19
20/// Node terms that carry their own text (materialised as a leading token child).
21const TEXT_BEARING: [&str; 18] = [
22    "assignment",
23    "export",
24    "local",
25    "comment",
26    "name",
27    "literal",
28    "variable",
29    "env-variable",
30    "positional",
31    "script-name",
32    "all-arguments",
33    "argument-count",
34    "exit-status",
35    "process-id",
36    "unsupported-expansion",
37    "default-expansion",
38    "pattern",
39    "redirect",
40];
41
42/// The variable names a script binds, split by how they must be declared in
43/// JavaScript.
44#[derive(Debug, Default, Clone)]
45pub struct DeclaredNames {
46    /// Every name that becomes a JavaScript variable, in first-seen order.
47    pub bound: Vec<String>,
48    /// The subset already declared at its use site (`local`, `for` variables).
49    pub locals: HashSet<String>,
50}
51
52impl DeclaredNames {
53    fn bind(&mut self, name: &str) {
54        if !self.bound.iter().any(|bound| bound == name) {
55            self.bound.push(name.to_string());
56        }
57    }
58
59    /// The names of [`Self::bound`] that still need a hoisted declaration.
60    pub fn hoisted(&self) -> Vec<&str> {
61        self.bound
62            .iter()
63            .filter(|name| !self.locals.contains(*name))
64            .map(String::as_str)
65            .collect()
66    }
67}
68
69/// Collects the variable names the script binds.
70///
71/// `export X=v` deliberately does not create a binding: it writes to the
72/// environment, so `$X` must translate to `process.env.X`.
73fn collect_declared_names(tree: &Node, names: &mut DeclaredNames) {
74    if let Some(text) = tree.text.as_deref() {
75        if tree.term == "assignment" || tree.term == "local" {
76            names.bind(text);
77            if tree.term == "local" {
78                names.locals.insert(text.to_string());
79            }
80        }
81    }
82    if tree.term == "for" {
83        if let Some(text) = tree
84            .children
85            .first()
86            .and_then(|child| child.text.as_deref())
87        {
88            names.bind(text);
89            names.locals.insert(text.to_string());
90        }
91    }
92    for child in &tree.children {
93        collect_declared_names(child, names);
94    }
95}
96
97/// Rewrites every `word` leaf into a `word` node of typed parts.
98fn expand_words(tree: Node, declared: &HashSet<String>) -> Node {
99    if tree.term == "word" {
100        let parse_substitution = |source: &str| {
101            let parsed = parse_shell_script(source);
102            expand_words(parsed, declared)
103        };
104        let parts = expand_word(
105            tree.text.as_deref().unwrap_or_default(),
106            declared,
107            &parse_substitution,
108        );
109        return Node::new("word", parts);
110    }
111    Node {
112        term: tree.term,
113        text: tree.text,
114        children: tree
115            .children
116            .into_iter()
117            .map(|child| expand_words(child, declared))
118            .collect(),
119    }
120}
121
122/// The capability profile of the shell dialect this translator formalizes.
123///
124/// Declaring it in the network makes the supported surface queryable rather
125/// than implicit, and `validate_network` then rejects anything outside it.
126pub fn shell_profile(concepts: &[String]) -> LanguageProfile {
127    let mut profile = LanguageProfile::new("command-stream shell", SHELL_LANGUAGE)
128        .with_link_type(LinkType::Syntax)
129        .with_link_type(LinkType::Token)
130        .with_link_type(LinkType::Semantic);
131    for concept in concepts {
132        profile = profile.with_concept(concept.clone());
133    }
134    profile
135}
136
137/// Inserts a syntax node whose child count is known at run time.
138fn insert_syntax_node(network: &mut LinkNetwork, term: &str, children: Vec<LinkId>) -> LinkId {
139    network.insert_dynamic_syntax_node(SHELL_LANGUAGE, term, &children)
140}
141
142/// A formalized shell script.
143pub struct Formalization {
144    pub network: LinkNetwork,
145    /// The `script` root link.
146    pub root: LinkId,
147    /// Every node term used, so callers can build the right preamble.
148    pub terms: HashSet<String>,
149    pub names: DeclaredNames,
150}
151
152fn insert_tree(network: &mut LinkNetwork, node: &Node, terms: &mut HashSet<String>) -> LinkId {
153    terms.insert(node.term.clone());
154    let mut children = Vec::new();
155    if TEXT_BEARING.contains(&node.term.as_str()) || node.text.is_some() {
156        children
157            .push(network.insert_source_token(SHELL_LANGUAGE, node.text.as_deref().unwrap_or("")));
158    }
159    for child in &node.children {
160        children.push(insert_tree(network, child, terms));
161    }
162    insert_syntax_node(network, &node.term, children)
163}
164
165/// Formalizes shell source as a links network.
166pub fn formalize_shell(source: &str) -> Formalization {
167    let parsed = parse_shell_script(source);
168    let mut names = DeclaredNames::default();
169    collect_declared_names(&parsed, &mut names);
170
171    let declared: HashSet<String> = names.bound.iter().cloned().collect();
172    let tree = expand_words(parsed, &declared);
173
174    let mut network = LinkNetwork::new();
175    let mut terms = HashSet::new();
176    let root = insert_tree(&mut network, &tree, &mut terms);
177
178    Formalization {
179        network,
180        root,
181        terms,
182        names,
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn builds_a_typed_network() {
192        let formalization = formalize_shell("NAME=world\necho \"hi $NAME\"\n");
193        assert!(formalization.terms.contains("assignment"));
194        assert!(formalization.terms.contains("variable"));
195        assert_eq!(formalization.names.bound, vec!["NAME".to_string()]);
196
197        let root = formalization
198            .network
199            .link(formalization.root)
200            .expect("root link exists");
201        assert_eq!(root.metadata().term(), Some("script"));
202        assert_eq!(root.metadata().link_type(), Some(LinkType::Syntax));
203    }
204
205    #[test]
206    fn inserts_nodes_of_dynamic_arity() {
207        let commands: String = (0..20)
208            .map(|index| format!("echo {index}\n"))
209            .collect::<Vec<_>>()
210            .concat();
211        let formalization = formalize_shell(&commands);
212        let root = formalization
213            .network
214            .link(formalization.root)
215            .expect("root link exists");
216        // The upstream dynamic API retains the complete ordered child list.
217        assert_eq!(root.references().len(), 20);
218        assert!(formalization.terms.contains("command"));
219    }
220
221    #[test]
222    fn the_profile_accepts_the_formalized_network() {
223        let formalization = formalize_shell("ls | wc -l\n");
224        let concepts: Vec<String> = formalization.terms.iter().cloned().collect();
225        assert!(shell_profile(&concepts)
226            .validate_network(&formalization.network)
227            .is_ok());
228    }
229}