diff --git a/src/rules/function-no-unknown/__tests__/index.js b/src/rules/function-no-unknown/__tests__/index.js index c03411e1..8afb4593 100644 --- a/src/rules/function-no-unknown/__tests__/index.js +++ b/src/rules/function-no-unknown/__tests__/index.js @@ -23,6 +23,78 @@ testRule({ }, { code: "a { color: color.adjust(#6b717f, $red: 15); }" + }, + { + code: ` + @use 'mymodule'; + + .foobar { + property: mymodule.myfunction(); + } + `, + description: "@use namespaced function. issue #760" + }, + { + code: ` + @use "mymodule"; + + .foobar { + property: mymodule.myfunction(); + } + `, + description: "@use namespaced function. issue #760" + }, + { + code: ` + @use "src/mymodule"; + + .foobar { + property: mymodule.myfunction(); + } + `, + description: + "@use namespaced function (By default, the namespace is just the last component of the module's URL.) issue #760" + }, + { + code: ` + @use "src/mymodule" as m; + + .foobar { + property: m.myfunction(); + } + `, + description: "@use namespaced function, 'as' keyword. issue #760" + }, + { + code: ` + @use 'library' with ( + $black: #222, + $border-radius: 0.1rem + ); + + .foobar { + property: library.fn(); + } + `, + description: "@use namespaced function, 'with' keyword. issue #760" + }, + { + code: ` + @use 'sass:math'; + + $half: math.percentage(1/2); + `, + description: "@use built-in function." + }, + { + code: ` + @use 'sass:map'; + @use 'sass:string'; + + $map-get: map.get(('key': 'value'), 'key'); + $str-index: string.index('string', 'i'); + `, + description: "@use built-in function." } ], @@ -38,6 +110,45 @@ testRule({ message: messages.rejected("color.unknown"), line: 1, column: 12 + }, + { + code: ` + @use 'mymodule'; + + .foobar { + property: othermodule.myfunction(); + } + `, + message: messages.rejected("othermodule.myfunction"), + line: 5, + column: 19, + description: "non-matching @use namespace" + }, + { + code: ` + @use "mymodule" as m; + + .foobar { + property: c.myfunction(); + } + `, + message: messages.rejected("c.myfunction"), + line: 5, + column: 19, + description: "non-matching @use namespace, 'as' keyword" + }, + { + code: ` + @use 'something' as * + + .class { + color: myFn(); + } + `, + message: messages.rejected("myFn"), + line: 5, + column: 16, + description: "@use without a namespace" } ] }); diff --git a/src/rules/function-no-unknown/index.js b/src/rules/function-no-unknown/index.js index abbe15f1..8167b4ad 100644 --- a/src/rules/function-no-unknown/index.js +++ b/src/rules/function-no-unknown/index.js @@ -19,6 +19,32 @@ export const meta = { url: ruleUrl(ruleName) }; +function isNamespacedFunction(fn) { + const namespacedFunc = /^\w+\.\w+$/; + return namespacedFunc.test(fn); +} + +function isAtUseAsSyntax(nodes) { + const [first, second, third] = nodes.slice(-3); + return ( + first.type === "word" && + first.value === "as" && + second.type === "space" && + third.type === "word" + ); +} + +function getAtUseNamespace(nodes) { + if (isAtUseAsSyntax(nodes)) { + const [last] = nodes.slice(-1); + return last.value; + } + const [first] = nodes; + const parts = first.value.split("/"); + const [last] = parts.slice(-1); + return last; +} + export default function rule(primaryOption, secondaryOptions) { return (root, result) => { const validOptions = utils.validateOptions( @@ -65,6 +91,22 @@ export default function rule(primaryOption, secondaryOptions) { return; } + if (isNamespacedFunction(funcName)) { + const atUseNamespaces = []; + + root.walkAtRules(/^use$/i, atRule => { + const { nodes } = valueParser(atRule.params); + atUseNamespaces.push(getAtUseNamespace(nodes)); + }); + + if (atUseNamespaces.length) { + const [namespace] = funcName.split("."); + if (atUseNamespaces.includes(namespace)) { + return; + } + } + } + if (!ignoreFunctionsAsSet.has(funcName)) { utils.report({ message: messages.rejected(funcName),