diff --git a/lib/src/ast/sass/statement/callable_declaration.dart b/lib/src/ast/sass/statement/callable_declaration.dart index 2e8b42d5..f1a6eccd 100644 --- a/lib/src/ast/sass/statement/callable_declaration.dart +++ b/lib/src/ast/sass/statement/callable_declaration.dart @@ -7,6 +7,7 @@ import 'package:source_span/source_span.dart'; import '../argument_declaration.dart'; import '../statement.dart'; import 'parent.dart'; +import 'silent_comment.dart'; /// An abstract class for callables (functions or mixins) that are declared in /// user code. @@ -16,12 +17,17 @@ abstract class CallableDeclaration extends ParentStatement { /// This may be `null` for callables without names. final String name; + /// The comment immediately preceding this declaration. + final SilentComment comment; + /// The declared arguments this callable accepts. final ArgumentDeclaration arguments; final FileSpan span; CallableDeclaration( - this.name, this.arguments, Iterable children, this.span) - : super(List.unmodifiable(children)); + this.name, this.arguments, Iterable children, this.span, + {SilentComment comment}) + : comment = comment, + super(List.unmodifiable(children)); } diff --git a/lib/src/ast/sass/statement/function_rule.dart b/lib/src/ast/sass/statement/function_rule.dart index 707b671c..78bcd4d9 100644 --- a/lib/src/ast/sass/statement/function_rule.dart +++ b/lib/src/ast/sass/statement/function_rule.dart @@ -8,14 +8,16 @@ import '../../../visitor/interface/statement.dart'; import '../argument_declaration.dart'; import '../statement.dart'; import 'callable_declaration.dart'; +import 'silent_comment.dart'; /// A function declaration. /// /// This declares a function that's invoked using normal CSS function syntax. class FunctionRule extends CallableDeclaration { FunctionRule(String name, ArgumentDeclaration arguments, - Iterable children, FileSpan span) - : super(name, arguments, children, span); + Iterable children, FileSpan span, + {SilentComment comment}) + : super(name, arguments, children, span, comment: comment); T accept(StatementVisitor visitor) => visitor.visitFunctionRule(this); diff --git a/lib/src/ast/sass/statement/mixin_rule.dart b/lib/src/ast/sass/statement/mixin_rule.dart index ad7a5a1f..7256e92c 100644 --- a/lib/src/ast/sass/statement/mixin_rule.dart +++ b/lib/src/ast/sass/statement/mixin_rule.dart @@ -8,6 +8,7 @@ import '../../../visitor/interface/statement.dart'; import '../argument_declaration.dart'; import '../statement.dart'; import 'callable_declaration.dart'; +import 'silent_comment.dart'; /// A mixin declaration. /// @@ -23,8 +24,8 @@ class MixinRule extends CallableDeclaration { /// won't work correctly. MixinRule(String name, ArgumentDeclaration arguments, Iterable children, FileSpan span, - {this.hasContent = false}) - : super(name, arguments, children, span); + {this.hasContent = false, SilentComment comment}) + : super(name, arguments, children, span, comment: comment); T accept(StatementVisitor visitor) => visitor.visitMixinRule(this); diff --git a/lib/src/ast/sass/statement/silent_comment.dart b/lib/src/ast/sass/statement/silent_comment.dart index 7cffc2a7..be68207c 100644 --- a/lib/src/ast/sass/statement/silent_comment.dart +++ b/lib/src/ast/sass/statement/silent_comment.dart @@ -2,7 +2,10 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'dart:math' as math; + import 'package:source_span/source_span.dart'; +import 'package:string_scanner/string_scanner.dart'; import '../../../visitor/interface/statement.dart'; import '../statement.dart'; @@ -12,6 +15,24 @@ class SilentComment implements Statement { /// The text of this comment, including comment characters. final String text; + /// The subset of lines in text that are marked as part of the documentation + /// comments by beginning with '///'. + /// + /// The leading slashes and space on each line is removed. Returns `null` when + /// there is no documentation comment. + String get docComment { + var buffer = StringBuffer(); + for (var line in text.split('\n')) { + var scanner = StringScanner(line.trim()); + if (!scanner.scan('///')) continue; + scanner.scan(' '); + buffer.writeln(scanner.rest); + } + var comment = buffer.toString().trimRight(); + + return comment.isNotEmpty ? comment : null; + } + final FileSpan span; SilentComment(this.text, this.span); diff --git a/lib/src/ast/sass/statement/variable_declaration.dart b/lib/src/ast/sass/statement/variable_declaration.dart index ae807dd0..17925e50 100644 --- a/lib/src/ast/sass/statement/variable_declaration.dart +++ b/lib/src/ast/sass/statement/variable_declaration.dart @@ -9,6 +9,7 @@ import '../../../parse/scss.dart'; import '../../../visitor/interface/statement.dart'; import '../expression.dart'; import '../statement.dart'; +import 'silent_comment.dart'; /// A variable declaration. /// @@ -17,6 +18,9 @@ class VariableDeclaration implements Statement { /// The name of the variable. final String name; + /// The comment immediatly preceding this declaration. + SilentComment comment; + /// The value the variable is being assigned to. final Expression expression; @@ -33,9 +37,10 @@ class VariableDeclaration implements Statement { final FileSpan span; VariableDeclaration(this.name, this.expression, this.span, - {bool guarded = false, bool global = false}) + {bool guarded = false, bool global = false, SilentComment comment}) : isGuarded = guarded, - isGlobal = global; + isGlobal = global, + comment = comment; /// Parses a variable declaration from [contents]. /// diff --git a/lib/src/parse/sass.dart b/lib/src/parse/sass.dart index 3a600099..6df58fc4 100644 --- a/lib/src/parse/sass.dart +++ b/lib/src/parse/sass.dart @@ -185,28 +185,46 @@ class SassParser extends StylesheetParser { SilentComment _silentComment() { var start = scanner.state; scanner.expect("//"); - var buffer = StringBuffer(); var parentIndentation = currentIndentation; - while (true) { - buffer.write("//"); - // Skip the first two indentation characters because we're already writing - // "//". - for (var i = 2; i < currentIndentation - parentIndentation; i++) { - buffer.writeCharCode($space); + outer: + do { + var commentPrefix = scanner.scanChar($slash) ? "///" : "//"; + + while (true) { + buffer.write(commentPrefix); + + // Skip the initial characters because we're already writing the + // slashes. + for (var i = commentPrefix.length; + i < currentIndentation - parentIndentation; + i++) { + buffer.writeCharCode($space); + } + + while (!scanner.isDone && !isNewline(scanner.peekChar())) { + buffer.writeCharCode(scanner.readChar()); + } + buffer.writeln(); + + if (_peekIndentation() < parentIndentation) break outer; + + if (_peekIndentation() == parentIndentation) { + // Look ahead to the next line to see if it starts another comment. + if (scanner.peekChar(1 + parentIndentation) == $slash && + scanner.peekChar(2 + parentIndentation) == $slash) { + _readIndentation(); + } + break; + } + _readIndentation(); } + } while (scanner.scan("//")); - while (!scanner.isDone && !isNewline(scanner.peekChar())) { - buffer.writeCharCode(scanner.readChar()); - } - buffer.writeln(); - - if (_peekIndentation() <= parentIndentation) break; - _readIndentation(); - } - - return SilentComment(buffer.toString(), scanner.spanFrom(start)); + lastSilentComment = + SilentComment(buffer.toString(), scanner.spanFrom(start)); + return lastSilentComment; } /// Consumes an indented-style loud context. diff --git a/lib/src/parse/scss.dart b/lib/src/parse/scss.dart index b2b91d62..559d336b 100644 --- a/lib/src/parse/scss.dart +++ b/lib/src/parse/scss.dart @@ -158,8 +158,9 @@ class ScssParser extends StylesheetParser { scanner.spanFrom(start)); } - return SilentComment( + lastSilentComment = SilentComment( scanner.substring(start.position), scanner.spanFrom(start)); + return lastSilentComment; } /// Consumes a statement-level loud comment block. diff --git a/lib/src/parse/stylesheet.dart b/lib/src/parse/stylesheet.dart index c436c87b..ec4aa0e5 100644 --- a/lib/src/parse/stylesheet.dart +++ b/lib/src/parse/stylesheet.dart @@ -57,6 +57,10 @@ abstract class StylesheetParser extends Parser { /// Whether the parser is currently within a parenthesized expression. var _inParentheses = false; + /// The silent comment this parser encountered previously. + @protected + SilentComment lastSilentComment; + StylesheetParser(String contents, {url, Logger logger}) : super(contents, url: url, logger: logger); @@ -147,6 +151,8 @@ abstract class StylesheetParser extends Parser { /// Consumes a variable declaration. @protected VariableDeclaration variableDeclaration() { + var precedingComment = lastSilentComment; + lastSilentComment = null; var start = scanner.state; var name = variableName(); @@ -179,7 +185,7 @@ abstract class StylesheetParser extends Parser { expectStatementSeparator("variable declaration"); return VariableDeclaration(name, value, scanner.spanFrom(start), - guarded: guarded, global: global); + guarded: guarded, global: global, comment: precedingComment); } /// Consumes a style rule. @@ -681,6 +687,8 @@ abstract class StylesheetParser extends Parser { /// /// [start] should point before the `@`. FunctionRule _functionRule(LineScannerState start) { + var precedingComment = lastSilentComment; + lastSilentComment = null; var name = identifier(); whitespace(); var arguments = _argumentDeclaration(); @@ -708,7 +716,8 @@ abstract class StylesheetParser extends Parser { whitespace(); var children = this.children(_functionAtRule); - return FunctionRule(name, arguments, children, scanner.spanFrom(start)); + return FunctionRule(name, arguments, children, scanner.spanFrom(start), + comment: precedingComment); } /// Consumes a `@for` rule. @@ -937,6 +946,8 @@ abstract class StylesheetParser extends Parser { /// /// [start] should point before the `@`. MixinRule _mixinRule(LineScannerState start) { + var precedingComment = lastSilentComment; + lastSilentComment = null; var name = identifier(); whitespace(); var arguments = scanner.peekChar() == $lparen @@ -960,7 +971,7 @@ abstract class StylesheetParser extends Parser { _mixinHasContent = null; return MixinRule(name, arguments, children, scanner.spanFrom(start), - hasContent: hadContent); + hasContent: hadContent, comment: precedingComment); } /// Consumes a `@moz-document` rule. diff --git a/test/doc_comments_test.dart b/test/doc_comments_test.dart new file mode 100644 index 00000000..9abdbcb8 --- /dev/null +++ b/test/doc_comments_test.dart @@ -0,0 +1,200 @@ +// Copyright 2018 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'package:sass/src/ast/sass.dart'; +import 'package:test/test.dart'; + +void main() { + group('documentation comments', () { + group('in SCSS', () { + test('attach to variable declarations', () { + final contents = r''' + /// Results my vary. + $vary: 5.16em;'''; + final stylesheet = Stylesheet.parseScss(contents); + final variable = + stylesheet.children.whereType().first; + + expect(variable.comment.docComment, equals('Results my vary.')); + }); + + test('attach to function rules', () { + final contents = r''' + /// A fun function! + @function fun($val) { + // Not a doc comment. + @return ($val / 1000) * 1em; + }'''; + final stylesheet = Stylesheet.parseScss(contents); + final function = stylesheet.children.whereType().first; + + expect(function.comment.docComment, equals('A fun function!')); + }); + + test('attach to mixin rules', () { + final contents = r''' + /// Mysterious mixin. + @mixin mystery { + // All black. + color: black; + background-color: black; + }'''; + final stylesheet = Stylesheet.parseScss(contents); + final mix = stylesheet.children.whereType().first; + + expect(mix.comment.docComment, equals('Mysterious mixin.')); + }); + + test('are null when there are no triple-slash comments', () { + final contents = r''' + // Regular comment. + $vary: 5.16em;'''; + final stylesheet = Stylesheet.parseScss(contents); + final variable = + stylesheet.children.whereType().first; + + expect(variable.comment.docComment, isNull); + }); + + test('are not carried over across members', () { + final contents = r''' + /// Mysterious mixin. + @mixin mystery { + // All black. + color: black; + background-color: black; + } + + /// A fun function! + @function fun($val) { + // Not a doc comment. + @return ($val / 1000) * 1em; + }'''; + final stylesheet = Stylesheet.parseScss(contents); + final mix = stylesheet.children.whereType().first; + final function = stylesheet.children.whereType().first; + + expect(mix.comment.docComment, equals('Mysterious mixin.')); + expect(function.comment.docComment, equals('A fun function!')); + }); + + test('do not include double-slash comments', () { + final contents = r''' + // Not a doc comment. + /// Line 1 + /// Line 2 + // Not a doc comment. + /// Line 3 + // Not a doc comment. + $vary: 5.16em;'''; + final stylesheet = Stylesheet.parseScss(contents); + final variable = + stylesheet.children.whereType().first; + + expect(variable.comment.docComment, equals('Line 1\nLine 2\nLine 3')); + }); + }); + + group('in indented syntax', () { + test('attach to variable declarations', () { + final contents = r''' +/// Results my vary. +$vary: 5.16em'''; + final stylesheet = Stylesheet.parseSass(contents); + final variable = + stylesheet.children.whereType().first; + + expect(variable.comment.docComment, equals('Results my vary.')); + }); + + test('attach to function rules', () { + final contents = r''' +/// A fun function! +@function fun($val) + // Not a doc comment. + @return ($val / 1000) * 1em'''; + final stylesheet = Stylesheet.parseSass(contents); + final function = stylesheet.children.whereType().first; + + expect(function.comment.docComment, equals('A fun function!')); + }); + + test('attach to mixin rules', () { + final contents = r''' +/// Mysterious mixin. +@mixin mystery + // All black. + color: black + background-color: black'''; + final stylesheet = Stylesheet.parseSass(contents); + final mix = stylesheet.children.whereType().first; + + expect(mix.comment.docComment, equals('Mysterious mixin.')); + }); + + test('are null when there are no triple-slash comments', () { + final contents = r''' +// Regular comment. +$vary: 5.16em'''; + final stylesheet = Stylesheet.parseSass(contents); + final variable = + stylesheet.children.whereType().first; + + expect(variable.comment.docComment, isNull); + }); + + test('are not carried over across members', () { + final contents = r''' +/// Mysterious mixin. +@mixin mystery + // All black. + color: black + background-color: black + +/// A fun function! +@function fun($val) + // Not a doc comment. + @return ($val / 1000) * 1em'''; + final stylesheet = Stylesheet.parseSass(contents); + final mix = stylesheet.children.whereType().first; + final function = stylesheet.children.whereType().first; + + expect(mix.comment.docComment, equals('Mysterious mixin.')); + expect(function.comment.docComment, equals('A fun function!')); + }); + + test('do not include double-slash comments', () { + final contents = r''' +// Not a doc comment. +/// Line 1 + Line 2 +// Not a doc comment. + Should be ignored. +$vary: 5.16em'''; + final stylesheet = Stylesheet.parseSass(contents); + final variable = + stylesheet.children.whereType().first; + + expect(variable.comment.docComment, equals('Line 1\nLine 2')); + }); + + test('are compacted into one from adjacent comments', () { + final contents = r''' +// Not a doc comment. +/// Line 1 +/// Line 2 + Line 3 +/// Line 4 +$vary: 5.16em'''; + final stylesheet = Stylesheet.parseSass(contents); + final variable = + stylesheet.children.whereType().first; + + expect(stylesheet.children.length, equals(2)); + expect(variable.comment.docComment, + equals('Line 1\nLine 2\nLine 3\nLine 4')); + }); + }); + }); +}