Skip to main content

weavatrix_parse/
python.rs

1//! Structural extraction for Python.
2//!
3//! Python scopes by indentation rather than braces, so the walk tracks the
4//! column a declaration was written at and closes it when a later declaration
5//! appears at the same column or further left. Working from token columns
6//! rather than raw line prefixes keeps this correct inside triple-quoted
7//! strings, where a line that looks like `def x():` is text, not code.
8
9use crate::facts::{
10    Declaration, DeclarationKind, Facts, Import, ImportBinding, Reference, ReferenceKind, Span,
11};
12use crate::syntax::Language;
13use crate::token::{Mode, Token, TokenKind, Tokenizer};
14
15/// Extracts structural facts from one Python source.
16#[must_use]
17pub fn extract(source: &str) -> Facts {
18    let tokens = Tokenizer::new(source, Language::Python)
19        .mode(Mode::Lite)
20        .collect::<Vec<_>>();
21    let mut state = Extractor {
22        source,
23        tokens: &tokens,
24        facts: Facts::default(),
25        scopes: Vec::new(),
26    };
27    state.run();
28    state.facts
29}
30
31/// A `def` or `class` whose indented body the walk is inside.
32struct Scope {
33    name: String,
34    column: u32,
35}
36
37struct Extractor<'source, 'tokens> {
38    source: &'source str,
39    tokens: &'tokens [Token],
40    facts: Facts,
41    scopes: Vec<Scope>,
42}
43
44impl Extractor<'_, '_> {
45    fn run(&mut self) {
46        let mut index = 0;
47        while index < self.tokens.len() {
48            index = self.step(index);
49        }
50    }
51
52    fn text(&self, index: usize) -> &str {
53        self.tokens
54            .get(index)
55            .map_or("", |token| token.text(self.source))
56    }
57
58    fn kind(&self, index: usize) -> Option<TokenKind> {
59        self.tokens.get(index).map(|token| token.kind)
60    }
61
62    fn is(&self, index: usize, word: &str) -> bool {
63        self.kind(index) == Some(TokenKind::Identifier) && self.text(index) == word
64    }
65
66    fn punct(&self, index: usize, mark: &str) -> bool {
67        self.kind(index) == Some(TokenKind::Punctuation) && self.text(index) == mark
68    }
69
70    fn span(&self, start: usize, end: usize) -> Span {
71        let last_index = self.tokens.len().saturating_sub(1);
72        let first = &self.tokens[start.min(last_index)];
73        let last = &self.tokens[end.min(last_index)];
74        Span {
75            start: first.start,
76            end: last.end,
77            line: first.line,
78            column: first.column,
79            end_line: last.line,
80            end_column: last.column,
81        }
82    }
83
84    /// Closes every scope this column has left.
85    fn close_scopes(&mut self, column: u32) {
86        while self
87            .scopes
88            .last()
89            .is_some_and(|scope| column <= scope.column)
90        {
91            self.scopes.pop();
92        }
93    }
94
95    fn owner(&self) -> Option<String> {
96        self.scopes.last().map(|scope| scope.name.clone())
97    }
98
99    fn step(&mut self, index: usize) -> usize {
100        let column = self.tokens[index].column;
101        if self.kind(index) != Some(TokenKind::Identifier) {
102            return index + 1;
103        }
104        if self.is(index, "def") || self.is(index, "async") && self.is(index + 1, "def") {
105            let keyword = if self.is(index, "async") {
106                index + 1
107            } else {
108                index
109            };
110            return self.definition(index, keyword + 1, DeclarationKind::Function, column);
111        }
112        if self.is(index, "class") {
113            return self.definition(index, index + 1, DeclarationKind::Class, column);
114        }
115        if (self.is(index, "import") || self.is(index, "from"))
116            && let Some(next) = self.import(index)
117        {
118            return next;
119        }
120        if let Some(next) = self.call(index) {
121            return next;
122        }
123        index + 1
124    }
125
126    fn definition(
127        &mut self,
128        start: usize,
129        name_index: usize,
130        kind: DeclarationKind,
131        column: u32,
132    ) -> usize {
133        if self.kind(name_index) != Some(TokenKind::Identifier) {
134            return start + 1;
135        }
136        self.close_scopes(column);
137        let name = self.text(name_index).to_owned();
138        // A def written inside a class is a method of it.
139        let kind = if kind == DeclarationKind::Function && self.owner().is_some() {
140            DeclarationKind::Method
141        } else {
142            kind
143        };
144        self.facts.declarations.push(Declaration {
145            name: name.clone(),
146            kind,
147            span: self.span(start, name_index),
148            owner: self.owner(),
149            // Python exports by convention: a leading underscore is private.
150            exported: !name.starts_with('_'),
151        });
152        // `class Service(Base, Mixin):` names what it derives from, and those
153        // are the edges an architecture rule reasons about.
154        if kind == DeclarationKind::Class && self.punct(name_index + 1, "(") {
155            let limit = (name_index + 64).min(self.tokens.len());
156            let mut cursor = name_index + 2;
157            while cursor < limit && !self.punct(cursor, ")") {
158                if self.kind(cursor) == Some(TokenKind::Identifier)
159                    && !self.punct(cursor + 1, "=")
160                    && !self.punct(cursor.wrapping_sub(1), ".")
161                {
162                    self.facts.references.push(Reference {
163                        name: self.text(cursor).to_owned(),
164                        kind: ReferenceKind::Inherits,
165                        receiver: None,
166                        span: self.span(cursor, cursor),
167                        owner: Some(name.clone()),
168                        string_arguments: Vec::new(),
169                        name_arguments: Vec::new(),
170                    });
171                }
172                cursor += 1;
173            }
174        }
175        self.scopes.push(Scope { name, column });
176        name_index + 1
177    }
178
179    /// `import a.b`, `import a as b`, `from .pkg import x`, `from x import *`.
180    fn import(&mut self, index: usize) -> Option<usize> {
181        let from_form = self.is(index, "from");
182        let line = self.tokens[index].line;
183        let mut cursor = index + 1;
184        if from_form {
185            let mut specifier = String::new();
186            while cursor < self.tokens.len()
187                && self.tokens[cursor].line == line
188                && !self.is(cursor, "import")
189            {
190                let text = self.text(cursor);
191                if text == "." || self.kind(cursor) == Some(TokenKind::Identifier) {
192                    specifier.push_str(text);
193                }
194                cursor += 1;
195            }
196            if specifier.is_empty() || !self.is(cursor, "import") {
197                return None;
198            }
199            cursor += 1;
200            let (bindings, end) = self.python_bindings(cursor, line);
201            self.push_import(&specifier, index, end.saturating_sub(1), bindings);
202            return Some(end);
203        }
204
205        while cursor < self.tokens.len() && self.tokens[cursor].line == line {
206            let start = cursor;
207            let mut specifier = String::new();
208            while cursor < self.tokens.len()
209                && self.tokens[cursor].line == line
210                && !self.punct(cursor, ",")
211                && !self.is(cursor, "as")
212            {
213                let text = self.text(cursor);
214                if text == "." || self.kind(cursor) == Some(TokenKind::Identifier) {
215                    specifier.push_str(text);
216                }
217                cursor += 1;
218            }
219            if specifier.is_empty() {
220                return None;
221            }
222            let mut local = specifier
223                .split('.')
224                .next()
225                .unwrap_or(specifier.as_str())
226                .to_owned();
227            if self.is(cursor, "as") && self.kind(cursor + 1) == Some(TokenKind::Identifier) {
228                self.text(cursor + 1).clone_into(&mut local);
229                cursor += 2;
230            }
231            self.push_import(
232                &specifier,
233                start,
234                cursor.saturating_sub(1),
235                vec![ImportBinding {
236                    imported: specifier.clone(),
237                    local,
238                }],
239            );
240            if self.punct(cursor, ",") {
241                cursor += 1;
242            }
243        }
244        Some(cursor)
245    }
246
247    fn python_bindings(&self, start: usize, line: u32) -> (Vec<ImportBinding>, usize) {
248        let mut bindings = Vec::new();
249        let mut cursor = start;
250        while cursor < self.tokens.len() && self.tokens[cursor].line == line {
251            if self.kind(cursor) != Some(TokenKind::Identifier) {
252                cursor += 1;
253                continue;
254            }
255            let imported = self.text(cursor).to_owned();
256            let mut local = imported.clone();
257            if self.is(cursor + 1, "as") && self.kind(cursor + 2) == Some(TokenKind::Identifier) {
258                self.text(cursor + 2).clone_into(&mut local);
259                cursor += 3;
260            } else {
261                cursor += 1;
262            }
263            bindings.push(ImportBinding { imported, local });
264        }
265        (bindings, cursor)
266    }
267
268    fn push_import(
269        &mut self,
270        specifier: &str,
271        start: usize,
272        end: usize,
273        bindings: Vec<ImportBinding>,
274    ) {
275        let names = bindings
276            .iter()
277            .map(|binding| binding.local.clone())
278            .collect();
279        self.facts.imports.push(Import {
280            specifier: specifier.to_owned(),
281            span: self.span(start, end),
282            type_only: false,
283            reexport: false,
284            names,
285            bindings,
286        });
287    }
288
289    fn call(&mut self, index: usize) -> Option<usize> {
290        if !self.punct(index + 1, "(") {
291            return None;
292        }
293        let name = self.text(index).to_owned();
294        if matches!(
295            name.as_str(),
296            "if" | "while" | "for" | "return" | "print" | "def" | "class" | "except" | "with"
297        ) {
298            return None;
299        }
300        let receiver = (index >= 2
301            && self.punct(index - 1, ".")
302            && self.kind(index - 2) == Some(TokenKind::Identifier))
303        .then(|| self.text(index - 2).to_owned());
304        let mut arguments = Vec::new();
305        let mut scan = index + 2;
306        let mut depth = 1_i32;
307        let limit = (index + 256).min(self.tokens.len());
308        while scan < limit && depth > 0 {
309            if self.punct(scan, "(") {
310                depth += 1;
311            } else if self.punct(scan, ")") {
312                depth -= 1;
313            } else if depth == 1 && self.kind(scan) == Some(TokenKind::String) {
314                let raw = self.text(scan);
315                let trimmed = raw
316                    .trim_start_matches(['"', '\''])
317                    .trim_end_matches(['"', '\'']);
318                arguments.push(trimmed.to_owned());
319            }
320            scan += 1;
321        }
322        self.facts.references.push(Reference {
323            kind: ReferenceKind::Call,
324            name,
325            receiver,
326            span: self.span(index, index),
327            owner: self.owner(),
328            string_arguments: arguments,
329            name_arguments: Vec::new(),
330        });
331        Some(index + 1)
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::extract;
338    use crate::facts::{DeclarationKind, ImportBinding};
339
340    #[test]
341    fn methods_belong_to_their_class_and_indentation_closes_scopes() {
342        let source = "class Service:\n\
343             \x20   def run(self):\n\
344             \x20       return self.helper()\n\
345             \x20   def helper(self):\n\
346             \x20       return 1\n\
347             \n\
348             def module_level():\n\
349             \x20   return Service()\n";
350        let facts = extract(source);
351        let declared = facts
352            .declarations
353            .iter()
354            .map(|item| (item.name.as_str(), item.kind, item.owner.as_deref()))
355            .collect::<Vec<_>>();
356        assert_eq!(
357            declared,
358            [
359                ("Service", DeclarationKind::Class, None),
360                ("run", DeclarationKind::Method, Some("Service")),
361                ("helper", DeclarationKind::Method, Some("Service")),
362                ("module_level", DeclarationKind::Function, None),
363            ],
364            "dedenting to column one leaves the class"
365        );
366    }
367
368    #[test]
369    fn a_docstring_is_text_even_when_it_looks_like_code() {
370        let source = "def real():\n\
371             \x20   \"\"\"\n\
372             \x20   def fake():\n\
373             \x20       import nothing\n\
374             \x20   \"\"\"\n\
375             \x20   return 1\n";
376        let facts = extract(source);
377        assert_eq!(
378            facts
379                .declarations
380                .iter()
381                .map(|item| item.name.as_str())
382                .collect::<Vec<_>>(),
383            ["real"],
384            "the definition inside the docstring is not a declaration"
385        );
386        assert!(
387            facts.imports.is_empty(),
388            "the import inside the docstring is not a dependency"
389        );
390    }
391
392    #[test]
393    fn reads_the_import_forms_python_writes() {
394        let source = "import os\n\
395             import pkg.module\n\
396             import json, time\n\
397             import numpy as np\n\
398             from .relative import thing as local_thing\n\
399             from ..parent.pkg import other\n";
400        let imports = extract(source).imports;
401        let specifiers = imports
402            .iter()
403            .map(|import| import.specifier.as_str())
404            .collect::<Vec<_>>();
405        assert_eq!(
406            specifiers,
407            [
408                "os",
409                "pkg.module",
410                "json",
411                "time",
412                "numpy",
413                ".relative",
414                "..parent.pkg",
415            ]
416        );
417        let numpy = imports
418            .iter()
419            .find(|import| import.specifier == "numpy")
420            .expect("numpy import");
421        assert_eq!(numpy.names, ["np"]);
422        assert_eq!(
423            numpy.bindings,
424            [ImportBinding {
425                imported: "numpy".to_owned(),
426                local: "np".to_owned(),
427            }]
428        );
429        let relative = imports
430            .iter()
431            .find(|import| import.specifier == ".relative")
432            .expect("relative import");
433        assert_eq!(relative.names, ["local_thing"]);
434        assert_eq!(
435            relative.bindings,
436            [ImportBinding {
437                imported: "thing".to_owned(),
438                local: "local_thing".to_owned(),
439            }]
440        );
441    }
442
443    #[test]
444    fn underscore_names_are_not_exported() {
445        let facts = extract("def public():\n    pass\ndef _private():\n    pass\n");
446        let exported = facts
447            .declarations
448            .iter()
449            .map(|item| (item.name.as_str(), item.exported))
450            .collect::<Vec<_>>();
451        assert_eq!(exported, [("public", true), ("_private", false)]);
452    }
453}