diff --git a/core/deps.edn b/core/deps.edn index f03fb8c1..4a1031ce 100644 --- a/core/deps.edn +++ b/core/deps.edn @@ -7,7 +7,7 @@ enlive/enlive {:mvn/version "1.1.6"} hiccup/hiccup {:mvn/version "1.0.5"} rum/rum {:mvn/version "0.11.2"}}} - :test {:extra-paths ["test"] + :test {:extra-paths ["test" "dev"] :extra-deps {org.clojure/clojure {:mvn/version "1.10.3"} org.clojure/clojurescript {:mvn/version "1.10.879"} thheller/shadow-cljs {:mvn/version "2.15.5"} diff --git a/core/dev/uix/examples.cljs b/core/dev/uix/examples.cljs index 5fff3c61..e0451399 100644 --- a/core/dev/uix/examples.cljs +++ b/core/dev/uix/examples.cljs @@ -261,4 +261,4 @@ (defonce -init (let [root (uix.dom/create-root (js/document.getElementById "root"))] (uix.dom/render-root ($ app) root) - nil)) + nil)) \ No newline at end of file diff --git a/core/dev/uix/linters.clj b/core/dev/uix/linters.clj new file mode 100644 index 00000000..f653c11c --- /dev/null +++ b/core/dev/uix/linters.clj @@ -0,0 +1,35 @@ +(ns uix.linters + (:require + [cljs.analyzer :as ana] + [clojure.pprint :as pprint] + [uix.linter :as linter])) + +;; per element linting +(defmethod linter/lint-element :element/no-inline-styles [_ form env] + (let [[_ tag props & children] form] + (when (and (keyword? tag) + (map? props) + (contains? props :style)) + (linter/add-error! form :element/no-inline-styles (linter/form->loc (:style props)))))) + +(defmethod ana/error-message :element/no-inline-styles [_ _] + "Inline styles are not allowed, put them into a CSS file instead") + +;; Hooks linting +(defmethod linter/lint-hook-with-deps :hooks/too-many-lines [_ form env] + (when (> (count (str form)) 180) + (linter/add-error! form :hooks/too-many-lines (linter/form->loc form)))) + +(defmethod ana/error-message :hooks/too-many-lines [_ {:keys [source]}] + (str "React hook is too large to be declared directly in component's body, consider extracting it into a custom hook:\n\n" + (with-out-str (pprint/pprint `(~'defn ~'use-my-hook [] + ~source))))) + +;; Components linting +(defmethod linter/lint-component :component/kebab-case-name [_ form env] + (let [[_ sym] form] + (when-not (re-matches #"[a-z-]+" (str sym)) + (linter/add-error! form :component/kebab-case-name (linter/form->loc sym))))) + +(defmethod ana/error-message :component/kebab-case-name [_ _] + "Component name should be in kebab case") diff --git a/core/src/uix/core.clj b/core/src/uix/core.clj index c71e6830..b6613982 100644 --- a/core/src/uix/core.clj +++ b/core/src/uix/core.clj @@ -86,7 +86,7 @@ A component should have a single argument of props." [sym & fdecl] (let [[fname args fdecl] (parse-sig `defui sym fdecl)] - (uix.linter/lint! sym fdecl &env) + (uix.linter/lint! sym fdecl &form &env) (if (uix.lib/cljs-env? &env) (let [var-sym (-> (str (-> &env :ns :name) "/" sym) symbol (with-meta {:tag 'js})) body (uix.dev/with-fast-refresh var-sym fdecl)] @@ -108,7 +108,7 @@ [(first fdecl) (rest fdecl)] [(gensym "uix-fn") fdecl]) [fname args body] (parse-sig `fn sym fdecl)] - (uix.linter/lint! sym body &env) + (uix.linter/lint! sym body &form &env) (if (uix.lib/cljs-env? &env) (let [var-sym (with-meta sym {:tag 'js})] `(let [~var-sym ~(if (empty? args) @@ -131,8 +131,10 @@ DOM element: ($ :button#id.class {:on-click handle-click} \"click me\") React component: ($ title-bar {:title \"Title\"})" ([tag] + (uix.linter/lint-element* &form &env) (uix.compiler.aot/compile-element [tag] {:env &env})) ([tag props & children] + (uix.linter/lint-element* &form &env) (uix.compiler.aot/compile-element (into [tag props] children) {:env &env}))) ;; === Hooks === diff --git a/core/src/uix/linter.clj b/core/src/uix/linter.clj index 10e5a2b6..7240d2c8 100644 --- a/core/src/uix/linter.clj +++ b/core/src/uix/linter.clj @@ -42,30 +42,33 @@ (contains? effect-hooks (name (first form)))) (defn form->loc [form] - (select-keys form [:line :column])) + (select-keys (meta form) [:line :column])) (defn find-env-for-form [type form] (case type (::hook-in-branch ::hook-in-loop ::deps-coll-literal ::literal-value-in-deps ::unsafe-set-state ::missing-key) - (form->loc (meta form)) + (form->loc form) ::inline-function - (form->loc (meta (second form))) + (form->loc (second form)) ::deps-array-literal - (form->loc (meta (.-val form))) + (form->loc (.-val form)) nil)) -(defn add-error! [form type] - (swap! *component-context* update :errors conj {:source form - :source-context *source-context* - :type type - :env (find-env-for-form type form)})) +(defn add-error! + ([form type] + (add-error! form type (find-env-for-form type form))) + ([form type env] + (swap! *component-context* update :errors conj {:source form + :source-context *source-context* + :type type + :env env}))) -(defn- uix-element? [form] +(defn uix-element? [form] (and (list? form) (= '$ (first form)))) (defn- missing-key? [[_ _ attrs :as form]] @@ -269,19 +272,31 @@ (:name v) ", use `use-subscribe` hook instead.\n" "Read https://github.com/pitch-io/uix/blob/master/docs/interop-with-reagent.md#syncing-with-ratoms-and-re-frame for more context")) -(defn lint! [sym form env] +(defmulti lint-component (fn [type form env])) +(defmulti lint-element (fn [type form env])) +(defmulti lint-hook-with-deps (fn [type form env])) + +(defn- run-linters! [mf & args] + (doseq [[key f] (.getMethodTable ^clojure.lang.MultiFn mf)] + (apply f key args))) + +(defn- report-errors! + ([env] + (report-errors! env nil)) + ([env m] + (let [{:keys [errors]} @*component-context* + {:keys [column line]} env] + (run! #(ana/warning (:type %) + (or (:env %) env) + (merge {:column column :line line} m %)) + errors)))) + +(defn lint! [sym body form env] (binding [*component-context* (atom {:errors []})] - (lint-body! form) - (lint-re-frame! form env) - (let [{:keys [errors]} @*component-context* - {:keys [column line]} env] - (doseq [err errors] - (ana/warning (:type err) - (or (:env err) env) - (into {:name (str (-> env :ns :name) "/" sym) - :column column - :line line} - err)))))) + (lint-body! body) + (lint-re-frame! body env) + (run-linters! lint-component form env) + (report-errors! env {:name (str (-> env :ns :name) "/" sym)}))) ;; === Exhaustive Deps === @@ -490,5 +505,12 @@ (defn lint-exhaustive-deps! [env form f deps] (doseq [[error-type opts] (lint-exhaustive-deps env form f deps)] - (ana/warning error-type (or (:env opts) env) opts))) + (ana/warning error-type (or (:env opts) env) opts)) + (binding [*component-context* (atom {:errors []})] + (run-linters! lint-hook-with-deps form env) + (report-errors! env))) +(defn lint-element* [form env] + (binding [*component-context* (atom {:errors []})] + (uix.linter/run-linters! uix.linter/lint-element form env) + (report-errors! env))) diff --git a/core/test/uix/linter_test.clj b/core/test/uix/linter_test.clj index 833d9a49..e7f2f716 100644 --- a/core/test/uix/linter_test.clj +++ b/core/test/uix/linter_test.clj @@ -2,6 +2,7 @@ (:require [clojure.test :refer [deftest is testing]] [shadow.cljs.devtools.cli] [uix.linter] + [uix.linters] [clojure.string :as str])) ;; === Rules of Hooks === @@ -265,4 +266,12 @@ (is (str/includes? out-str "UIx element is missing :key attribute, which is required")) (is (str/includes? out-str "($ :div.test-missing-key {} x)")) (is (str/includes? out-str "($ :div.test-missing-key ($ x))")) - (is (str/includes? out-str "($ :div.test-missing-key-nested ($ x))"))))) + (is (str/includes? out-str "($ :div.test-missing-key-nested ($ x))"))) + + (testing "plugin linters should work" + (is (str/includes? out-str (str :component/kebab-case-name))) + (is (str/includes? out-str (str :hooks/too-many-lines))) + (is (str/includes? out-str (str :element/no-inline-styles))) + (is (str/includes? out-str "Component name should be in kebab case")) + (is (str/includes? out-str "React hook is too large to be declared directly in component's body")) + (is (str/includes? out-str "Inline styles are not allowed, put them into a CSS file instead"))))) diff --git a/core/test/uix/linter_test.cljs b/core/test/uix/linter_test.cljs index 17f120cc..cc48e7f8 100644 --- a/core/test/uix/linter_test.cljs +++ b/core/test/uix/linter_test.cljs @@ -65,3 +65,14 @@ (do ($ :div.test-missing-key ($ x)) ($ :div.test-missing-key-nested ($ x)))))) + +(defui Button [{:keys [on-click children]}] + (uix.core/use-effect + (fn [] + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + []) + ($ :button {:on-click on-click + :style {:color "red"}} + children))