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 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
37pub 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 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
98static 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 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 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 for comp in &source.components {
192 lint_jsx(&comp.jsx, content, file_path, &mut out);
193 }
194
195 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 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 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 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
347pub 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}