Skip to main content

morph_parser/
linter.rs

1use std::collections::HashSet;
2
3use crate::ast_types::{JsxNode, LintError, MxSource};
4
5#[allow(dead_code)]
6fn offset_to_line_col(source: &str, offset: u32) -> (usize, usize) {
7    let offset = offset as usize;
8    let mut line = 1usize;
9    let mut last = 0usize;
10    for (i, ch) in source.char_indices() {
11        if i >= offset { break; }
12        if ch == '\n' { line += 1; last = i + 1; }
13    }
14    (line, offset.saturating_sub(last) + 1)
15}
16
17fn build_line_offsets(source: &str) -> Vec<usize> {
18    let mut offsets = vec![0];
19    for (i, ch) in source.char_indices() {
20        if ch == '\n' { offsets.push(i + 1); }
21    }
22    offsets
23}
24
25fn offset_to_line_col_fast(offsets: &[usize], offset: u32) -> (usize, usize) {
26    let off = offset as usize;
27    // binary search for line
28    let line = match offsets.binary_search(&off) {
29        Ok(idx) => idx + 1,
30        Err(idx) => idx,
31    };
32    let line_start = offsets[line.saturating_sub(1)];
33    let col = off.saturating_sub(line_start) + 1;
34    (line, col)
35}
36
37/// Check a .mx file for parse + semantic errors (with caching support via caller)
38pub fn check(source: &str, file_path: &str) -> Vec<LintError> {
39    let offsets = build_line_offsets(source);
40    let allocator = oxc_allocator::Allocator::default();
41    let source_type = oxc_span::SourceType::from_path("file.tsx").unwrap();
42    let ret = oxc_parser::Parser::new(&allocator, source, source_type).parse();
43    let mut errors = Vec::new();
44
45    for diag in &ret.diagnostics {
46        let span = diag.labels.first().map(|l| l.span()).unwrap_or(oxc_span::Span::new(0, 0));
47        let (line, col) = offset_to_line_col_fast(&offsets, span.start);
48        errors.push(LintError {
49            severity: "error".into(),
50            code: "parse-error".into(),
51            message: diag.message.to_string(),
52            suggestion: diag.help.as_ref().map(|h| h.to_string()),
53            file_path: file_path.into(),
54            line,
55            col,
56        });
57    }
58
59    if ret.panicked {
60        errors.push(LintError {
61            severity: "error".into(),
62            code: "parse-panic".into(),
63            message: "Parser panicked — unrecoverable syntax error".into(),
64            suggestion: Some("Check for unmatched braces or truncated file".into()),
65            file_path: file_path.into(),
66            line: 1,
67            col: 1,
68        });
69        return errors;
70    }
71
72    // Walk program to build MxSource for semantic lints (even with parse errors, program is partial)
73    let mut walker = crate::js_walker::MxWalker::new(source);
74    {
75        use oxc_ast_visit::Visit;
76        walker.visit_program(&ret.program);
77    }
78    let mx_source = MxSource {
79        filename: file_path.into(),
80        imports: walker.imports,
81        window_config: walker.window_config,
82        components: walker.components,
83        state_vars: walker.state_vars,
84        effects: walker.effects,
85        inner_functions: walker.inner_functions,
86        function_declarations: walker.function_declarations,
87        global_vars: walker.global_vars,
88        console_logs: walker.console_logs,
89        extra_headers: walker.extra_headers,
90        cpp_imports: walker.cpp_imports,
91    };
92
93    let lints = lint(&mx_source, source, file_path);
94    errors.extend(lints);
95    errors
96}
97
98// ── Registry mirrors Python's checker/registry.py ──────────────────────────
99
100static SUPPORTED_TAGS: &[&str] = &[
101    "div","span","p","h1","h2","h3","h4","h5","h6","button","input","img","a",
102    "ul","ol","li","table","thead","tbody","tr","td","th","form","label",
103    "section","header","footer","nav","main","article","aside","body","view",
104    "text","morph-window","fragment",
105];
106
107static STUB_TAGS: &[&str] = &["select","textarea"];
108
109static GLOBAL_PROPS: &[&str] = &["className","class","id","style","key"];
110
111static EVENT_PROPS: &[&str] = &[
112    "onClick","onDoubleClick","onMouseDown","onMouseUp","onMouseEnter","onMouseLeave",
113    "onKeyUp","onKeyDown","onChange","onInput","onFocus","onBlur",
114];
115
116static TAG_PROPS: &[(&str, &[&str])] = &[
117    ("img", &["src","alt","width","height"]),
118    ("a", &["href","target"]),
119    ("input", &["value","maxLength","minLength","placeholder","disabled","type"]),
120    ("morph-window", &["title","width","height","minWidth","maxWidth","minHeight","maxHeight"]),
121];
122
123#[inline]
124fn is_supported_tag(tag: &str) -> bool {
125    SUPPORTED_TAGS.contains(&tag)
126}
127
128#[inline]
129fn is_component_tag(tag: &str) -> bool {
130    tag.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
131}
132
133#[inline]
134fn is_allowed_prop(tag: &str, prop: &str) -> bool {
135    if GLOBAL_PROPS.contains(&prop) { return true; }
136    if EVENT_PROPS.contains(&prop) { return true; }
137    if prop.starts_with("data-") || prop.starts_with("aria-") { return true; }
138    for (t, props) in TAG_PROPS {
139        if *t == tag && props.contains(&prop) { return true; }
140    }
141    false
142}
143
144static UNSUPPORTED_GLOBALS: &[&str] = &[
145    "document","window","localStorage","sessionStorage","navigator","location",
146    "history","screen","alert","prompt","confirm","requestAnimationFrame",
147];
148
149pub fn lint(source: &MxSource, content: &str, file_path: &str) -> Vec<LintError> {
150    let mut out = Vec::new();
151
152    // ── mx-export: exactly one export default function ──
153    if source.components.is_empty() {
154        out.push(LintError {
155            severity: "error".into(),
156            code: "mx-export".into(),
157            message: "No component found — expected `export default function App()`".into(),
158            suggestion: Some("Add `export default function App() { return (<div>...</div>) }`".into()),
159            file_path: file_path.into(),
160            line: 1, col: 1,
161        });
162    } else if source.components.iter().filter(|c| c.exported).count() > 1 {
163        out.push(LintError {
164            severity: "error".into(),
165            code: "mx-export".into(),
166            message: "Multiple default exports — only one allowed".into(),
167            suggestion: Some("Keep a single `export default function App()`".into()),
168            file_path: file_path.into(),
169            line: 1, col: 1,
170        });
171    }
172
173    // ── window checks ──
174    if source.window_config.is_none() {
175        if !source.components.is_empty() {
176            let has_morph_window = source.components.iter().any(|c| jsx_has_tag(&c.jsx, "morph-window"));
177            if !has_morph_window {
178                out.push(LintError {
179                    severity: "warning".into(),
180                    code: "mx-window-missing".into(),
181                    message: "Missing `windowConfig` export and no <morph-window>".into(),
182                    suggestion: Some("Add `export const windowConfig = { title: \"App\", width: 800, height: 600 }`".into()),
183                    file_path: file_path.into(),
184                    line: 1, col: 1,
185                });
186            }
187        }
188    }
189
190    // ── Walk JSX for tag/prop/list/JS global checks ──
191    for comp in &source.components {
192        lint_jsx(&comp.jsx, content, file_path, &mut out);
193    }
194
195    // ── JS globals scan (simple substring search with line info) ──
196    for (idx, line) in content.lines().enumerate() {
197        let trimmed = line.trim();
198        if trimmed.starts_with("//") || trimmed.starts_with("/*") { continue; }
199        for g in UNSUPPORTED_GLOBALS {
200            if line.contains(&format!("{g}.")) || line.contains(&format!("{g}[")) || line.contains(&format!(" {g} ")) {
201                let col = line.find(g).unwrap_or(0) + 1;
202                out.push(LintError {
203                    severity: "error".into(),
204                    code: "mx-js-global".into(),
205                    message: format!("`{g}` is not available in native runtime"),
206                    suggestion: Some(format!("Use Morph state / C++ instead of browser `{g}`")),
207                    file_path: file_path.into(),
208                    line: idx + 1, col,
209                });
210            }
211        }
212    }
213
214    // Deduplicate by code+line+col
215    let mut seen = HashSet::new();
216    out.retain(|e| seen.insert((e.code.clone(), e.line, e.col, e.message.clone())));
217
218    out
219}
220
221fn jsx_has_tag(node: &JsxNode, target: &str) -> bool {
222    match node {
223        JsxNode::Element { tag, children, .. } => {
224            if tag == target { return true; }
225            children.iter().any(|c| jsx_has_tag(c, target))
226        }
227        JsxNode::Fragment { children, .. } => children.iter().any(|c| jsx_has_tag(c, target)),
228        JsxNode::Conditional { then_branch, else_branch, .. } => {
229            then_branch.iter().any(|c| jsx_has_tag(c, target)) || else_branch.iter().any(|c| jsx_has_tag(c, target))
230        }
231        JsxNode::List { item_template, .. } => jsx_has_tag(item_template, target),
232        _ => false,
233    }
234}
235
236fn lint_jsx(node: &JsxNode, _content: &str, file_path: &str, out: &mut Vec<LintError>) {
237    match node {
238        JsxNode::Element { tag, props, children, line, col, .. } => {
239            // ── mx-tag ──
240            if !is_supported_tag(tag) && !is_component_tag(tag) && !tag.starts_with("__") {
241                if STUB_TAGS.contains(&tag.as_str()) {
242                    out.push(LintError {
243                        severity: "warning".into(),
244                        code: "mx-tag-stub".into(),
245                        message: format!("Tag <{tag}> is registered but not fully implemented"),
246                        suggestion: Some(format!("Use <div> with custom handling instead of <{tag}>")),
247                        file_path: file_path.into(),
248                        line: *line, col: *col,
249                    });
250                } else {
251                    let suggestion = suggest_tag(tag);
252                    out.push(LintError {
253                        severity: "error".into(),
254                        code: "mx-tag".into(),
255                        message: format!("Unknown tag <{tag}>"),
256                        suggestion: suggestion.map(|s| format!("Did you mean <{s}>?")),
257                        file_path: file_path.into(),
258                        line: *line, col: *col,
259                    });
260                }
261            }
262
263            // ── mx-prop ──
264            for (prop, _) in props {
265                if prop == "class" {
266                    out.push(LintError {
267                        severity: "warning".into(),
268                        code: "mx-prop".into(),
269                        message: format!("Use `className` instead of `class` on <{tag}>"),
270                        suggestion: Some("Replace `class` with `className`".into()),
271                        file_path: file_path.into(),
272                        line: *line, col: *col,
273                    });
274                    continue;
275                }
276                if !is_allowed_prop(tag, prop) {
277                    if prop.starts_with("on") {
278                        out.push(LintError {
279                            severity: "warning".into(),
280                            code: "mx-prop".into(),
281                            message: format!("Unknown prop `{prop}` on <{tag}>"),
282                            suggestion: Some("Check event name (onClick, onInput, etc.)".into()),
283                            file_path: file_path.into(),
284                            line: *line, col: *col,
285                        });
286                    } else if SUPPORTED_TAGS.contains(&tag.as_str()) && !is_component_tag(tag) && prop.len() < 20 {
287                        if let Some(s) = suggest_prop_for_tag(prop, tag) {
288                            out.push(LintError {
289                                severity: "warning".into(),
290                                code: "mx-prop".into(),
291                                message: format!("Unknown prop `{prop}` on <{tag}>"),
292                                suggestion: Some(format!("Did you mean `{s}`?")),
293                                file_path: file_path.into(),
294                                line: *line, col: *col,
295                            });
296                        }
297                    }
298                }
299            }
300
301            for child in children {
302                lint_jsx(child, _content, file_path, out);
303            }
304        }
305        JsxNode::Fragment { children, .. } => {
306            for child in children { lint_jsx(child, _content, file_path, out); }
307        }
308        JsxNode::Conditional { then_branch, else_branch, .. } => {
309            for c in then_branch { lint_jsx(c, _content, file_path, out); }
310            for c in else_branch { lint_jsx(c, _content, file_path, out); }
311        }
312        JsxNode::List { key_expr, item_template, line, col, .. } => {
313            if key_expr.is_empty() {
314                out.push(LintError {
315                    severity: "warning".into(),
316                    code: "mx-list-key".into(),
317                    message: "List rendering without `key` prop — may cause state mismatches".into(),
318                    suggestion: Some("Add `key={item.id}` to the root element inside `.map()`".into()),
319                    file_path: file_path.into(),
320                    line: *line, col: *col,
321                });
322            }
323            lint_jsx(item_template, _content, file_path, out);
324        }
325        _ => {}
326    }
327}
328
329fn suggest_prop_for_tag(input: &str, tag: &str) -> Option<String> {
330    let mut best: Option<(String, f64)> = None;
331    let candidates = GLOBAL_PROPS.iter().copied().chain(EVENT_PROPS.iter().copied()).chain(
332        TAG_PROPS.iter().filter(|(t,_)| *t==tag).flat_map(|(_, props)| props.iter().copied())
333    );
334    for prop in candidates {
335        let dist = strsim::levenshtein(input, prop) as f64;
336        let max_len = input.len().max(prop.len()) as f64;
337        let sim = 1.0 - (dist / max_len);
338        if sim > 0.6 {
339            if let Some((_, best_sim)) = &best {
340                if sim > *best_sim { best = Some((prop.to_string(), sim)); }
341            } else { best = Some((prop.to_string(), sim)); }
342        }
343    }
344    best.map(|(s,_)| s)
345}
346
347// Helper for tag suggestion (exposed to crate)
348pub fn suggest_tag(input: &str) -> Option<String> {
349    let mut best: Option<(String, f64)> = None;
350    for tag in SUPPORTED_TAGS {
351        let dist = strsim::levenshtein(input, tag) as f64;
352        let max_len = input.len().max(tag.len()) as f64;
353        let sim = 1.0 - (dist / max_len);
354        if sim > 0.5 {
355            if let Some((_, bs)) = &best { if sim > *bs { best = Some((tag.to_string(), sim)); } } else { best = Some((tag.to_string(), sim)); }
356        }
357    }
358    best.map(|(s,_)| s)
359}