Allow [doc] annotation on modules (#2247)

This commit is contained in:
Greg Shuflin 2024-07-14 22:15:22 -07:00 committed by GitHub
parent 023b126eb2
commit d5ebc9515e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 133 additions and 45 deletions

View File

@ -10,7 +10,7 @@ pub(crate) struct Analyzer<'src> {
impl<'src> Analyzer<'src> { impl<'src> Analyzer<'src> {
pub(crate) fn analyze( pub(crate) fn analyze(
asts: &HashMap<PathBuf, Ast<'src>>, asts: &HashMap<PathBuf, Ast<'src>>,
doc: Option<&'src str>, doc: Option<String>,
loaded: &[PathBuf], loaded: &[PathBuf],
name: Option<Name<'src>>, name: Option<Name<'src>>,
paths: &HashMap<PathBuf, PathBuf>, paths: &HashMap<PathBuf, PathBuf>,
@ -22,7 +22,7 @@ impl<'src> Analyzer<'src> {
fn justfile( fn justfile(
mut self, mut self,
asts: &HashMap<PathBuf, Ast<'src>>, asts: &HashMap<PathBuf, Ast<'src>>,
doc: Option<&'src str>, doc: Option<String>,
loaded: &[PathBuf], loaded: &[PathBuf],
name: Option<Name<'src>>, name: Option<Name<'src>>,
paths: &HashMap<PathBuf, PathBuf>, paths: &HashMap<PathBuf, PathBuf>,
@ -90,13 +90,27 @@ impl<'src> Analyzer<'src> {
absolute, absolute,
name, name,
doc, doc,
attributes,
.. ..
} => { } => {
let mut doc_attr: Option<&str> = None;
for attribute in attributes {
if let Attribute::Doc(ref doc) = attribute {
doc_attr = doc.as_ref().map(|s| s.cooked.as_ref());
} else {
return Err(name.token.error(InvalidAttribute {
item_kind: "Module",
item_name: name.lexeme(),
attribute: attribute.clone(),
}));
}
}
if let Some(absolute) = absolute { if let Some(absolute) = absolute {
define(*name, "module", false)?; define(*name, "module", false)?;
modules.insert(Self::analyze( modules.insert(Self::analyze(
asts, asts,
*doc, doc_attr.or(*doc).map(ToOwned::to_owned),
loaded, loaded,
Some(*name), Some(*name),
paths, paths,
@ -245,12 +259,11 @@ impl<'src> Analyzer<'src> {
} }
fn analyze_alias(alias: &Alias<'src, Name<'src>>) -> CompileResult<'src> { fn analyze_alias(alias: &Alias<'src, Name<'src>>) -> CompileResult<'src> {
let name = alias.name.lexeme();
for attribute in &alias.attributes { for attribute in &alias.attributes {
if *attribute != Attribute::Private { if *attribute != Attribute::Private {
return Err(alias.name.token.error(AliasInvalidAttribute { return Err(alias.name.token.error(InvalidAttribute {
alias: name, item_kind: "Alias",
item_name: alias.name.lexeme(),
attribute: attribute.clone(), attribute: attribute.clone(),
})); }));
} }

View File

@ -32,13 +32,6 @@ impl Display for CompileError<'_> {
use CompileErrorKind::*; use CompileErrorKind::*;
match &*self.kind { match &*self.kind {
AliasInvalidAttribute { alias, attribute } => {
write!(
f,
"Alias `{alias}` has invalid attribute `{}`",
attribute.name(),
)
}
AliasShadowsRecipe { alias, recipe_line } => write!( AliasShadowsRecipe { alias, recipe_line } => write!(
f, f,
"Alias `{alias}` defined on line {} shadows recipe `{alias}` defined on line {}", "Alias `{alias}` defined on line {} shadows recipe `{alias}` defined on line {}",
@ -150,6 +143,9 @@ impl Display for CompileError<'_> {
write!(f, "Variable {variable} is both exported and unexported") write!(f, "Variable {variable} is both exported and unexported")
} }
ExtraLeadingWhitespace => write!(f, "Recipe line has extra leading whitespace"), ExtraLeadingWhitespace => write!(f, "Recipe line has extra leading whitespace"),
ExtraneousAttributes { count } => {
write!(f, "Extraneous {}", Count("attribute", *count))
}
FunctionArgumentCountMismatch { FunctionArgumentCountMismatch {
function, function,
found, found,
@ -176,6 +172,15 @@ impl Display for CompileError<'_> {
"Internal error, this may indicate a bug in just: {message}\n\ "Internal error, this may indicate a bug in just: {message}\n\
consider filing an issue: https://github.com/casey/just/issues/new" consider filing an issue: https://github.com/casey/just/issues/new"
), ),
InvalidAttribute {
item_name,
item_kind,
attribute,
} => write!(
f,
"{item_kind} `{item_name}` has invalid attribute `{}`",
attribute.name(),
),
InvalidEscapeSequence { character } => write!( InvalidEscapeSequence { character } => write!(
f, f,
"`\\{}` is not a valid escape sequence", "`\\{}` is not a valid escape sequence",

View File

@ -2,10 +2,6 @@ use super::*;
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub(crate) enum CompileErrorKind<'src> { pub(crate) enum CompileErrorKind<'src> {
AliasInvalidAttribute {
alias: &'src str,
attribute: Attribute<'src>,
},
AliasShadowsRecipe { AliasShadowsRecipe {
alias: &'src str, alias: &'src str,
recipe_line: usize, recipe_line: usize,
@ -63,6 +59,9 @@ pub(crate) enum CompileErrorKind<'src> {
variable: &'src str, variable: &'src str,
}, },
ExtraLeadingWhitespace, ExtraLeadingWhitespace,
ExtraneousAttributes {
count: usize,
},
FunctionArgumentCountMismatch { FunctionArgumentCountMismatch {
function: &'src str, function: &'src str,
found: usize, found: usize,
@ -76,6 +75,11 @@ pub(crate) enum CompileErrorKind<'src> {
Internal { Internal {
message: String, message: String,
}, },
InvalidAttribute {
item_kind: &'static str,
item_name: &'src str,
attribute: Attribute<'src>,
},
InvalidEscapeSequence { InvalidEscapeSequence {
character: char, character: char,
}, },

View File

@ -13,6 +13,7 @@ pub(crate) enum Item<'src> {
relative: StringLiteral<'src>, relative: StringLiteral<'src>,
}, },
Module { Module {
attributes: BTreeSet<Attribute<'src>>,
absolute: Option<PathBuf>, absolute: Option<PathBuf>,
doc: Option<&'src str>, doc: Option<&'src str>,
name: Name<'src>, name: Name<'src>,

View File

@ -13,7 +13,7 @@ struct Invocation<'src: 'run, 'run> {
pub(crate) struct Justfile<'src> { pub(crate) struct Justfile<'src> {
pub(crate) aliases: Table<'src, Alias<'src>>, pub(crate) aliases: Table<'src, Alias<'src>>,
pub(crate) assignments: Table<'src, Assignment<'src>>, pub(crate) assignments: Table<'src, Assignment<'src>>,
pub(crate) doc: Option<&'src str>, pub(crate) doc: Option<String>,
#[serde(rename = "first", serialize_with = "keyed::serialize_option")] #[serde(rename = "first", serialize_with = "keyed::serialize_option")]
pub(crate) default: Option<Rc<Recipe<'src>>>, pub(crate) default: Option<Rc<Recipe<'src>>>,
#[serde(skip)] #[serde(skip)]

View File

@ -321,6 +321,14 @@ impl<'run, 'src> Parser<'run, 'src> {
self.accept(ByteOrderMark)?; self.accept(ByteOrderMark)?;
loop { loop {
let mut attributes = self.parse_attributes()?;
let mut take_attributes = || {
attributes
.take()
.map(|(_token, attributes)| attributes)
.unwrap_or_default()
};
let next = self.next()?; let next = self.next()?;
if let Some(comment) = self.accept(Comment)? { if let Some(comment) = self.accept(Comment)? {
@ -334,7 +342,7 @@ impl<'run, 'src> Parser<'run, 'src> {
} else if self.next_is(Identifier) { } else if self.next_is(Identifier) {
match Keyword::from_lexeme(next.lexeme()) { match Keyword::from_lexeme(next.lexeme()) {
Some(Keyword::Alias) if self.next_are(&[Identifier, Identifier, ColonEquals]) => { Some(Keyword::Alias) if self.next_are(&[Identifier, Identifier, ColonEquals]) => {
items.push(Item::Alias(self.parse_alias(BTreeSet::new())?)); items.push(Item::Alias(self.parse_alias(take_attributes())?));
} }
Some(Keyword::Export) if self.next_are(&[Identifier, Identifier, ColonEquals]) => { Some(Keyword::Export) if self.next_are(&[Identifier, Identifier, ColonEquals]) => {
self.presume_keyword(Keyword::Export)?; self.presume_keyword(Keyword::Export)?;
@ -388,6 +396,7 @@ impl<'run, 'src> Parser<'run, 'src> {
}; };
items.push(Item::Module { items.push(Item::Module {
attributes: take_attributes(),
absolute: None, absolute: None,
doc, doc,
name, name,
@ -412,7 +421,7 @@ impl<'run, 'src> Parser<'run, 'src> {
items.push(Item::Recipe(self.parse_recipe( items.push(Item::Recipe(self.parse_recipe(
doc, doc,
false, false,
BTreeSet::new(), take_attributes(),
)?)); )?));
} }
} }
@ -422,23 +431,17 @@ impl<'run, 'src> Parser<'run, 'src> {
items.push(Item::Recipe(self.parse_recipe( items.push(Item::Recipe(self.parse_recipe(
doc, doc,
true, true,
BTreeSet::new(), take_attributes(),
)?)); )?));
} else if let Some(attributes) = self.parse_attributes()? {
let next_keyword = Keyword::from_lexeme(self.next()?.lexeme());
match next_keyword {
Some(Keyword::Alias) if self.next_are(&[Identifier, Identifier, ColonEquals]) => {
items.push(Item::Alias(self.parse_alias(attributes)?));
}
_ => {
let quiet = self.accepted(At)?;
let doc = pop_doc_comment(&mut items, eol_since_last_comment);
items.push(Item::Recipe(self.parse_recipe(doc, quiet, attributes)?));
}
}
} else { } else {
return Err(self.unexpected_token()?); return Err(self.unexpected_token()?);
} }
if let Some((token, attributes)) = attributes {
return Err(token.error(CompileErrorKind::ExtraneousAttributes {
count: attributes.len(),
}));
}
} }
if self.next_token == self.tokens.len() { if self.next_token == self.tokens.len() {
@ -989,10 +992,16 @@ impl<'run, 'src> Parser<'run, 'src> {
} }
/// Parse recipe attributes /// Parse recipe attributes
fn parse_attributes(&mut self) -> CompileResult<'src, Option<BTreeSet<Attribute<'src>>>> { fn parse_attributes(
&mut self,
) -> CompileResult<'src, Option<(Token<'src>, BTreeSet<Attribute<'src>>)>> {
let mut attributes = BTreeMap::new(); let mut attributes = BTreeMap::new();
while self.accepted(BracketL)? { let mut token = None;
while let Some(bracket) = self.accept(BracketL)? {
token.get_or_insert(bracket);
loop { loop {
let name = self.parse_name()?; let name = self.parse_name()?;
@ -1029,7 +1038,7 @@ impl<'run, 'src> Parser<'run, 'src> {
if attributes.is_empty() { if attributes.is_empty() {
Ok(None) Ok(None)
} else { } else {
Ok(Some(attributes.into_keys().collect())) Ok(Some((token.unwrap(), attributes.into_keys().collect())))
} }
} }
} }

View File

@ -620,7 +620,7 @@ impl Subcommand {
format_doc( format_doc(
config, config,
submodule.name(), submodule.name(),
submodule.doc, submodule.doc.as_deref(),
max_signature_width, max_signature_width,
&signature_widths, &signature_widths,
); );

View File

@ -737,3 +737,53 @@ fn comments_can_follow_modules() {
.stdout("FOO\n") .stdout("FOO\n")
.run(); .run();
} }
#[test]
fn doc_comment_on_module() {
Test::new()
.write("foo.just", "")
.justfile(
"
# Comment
mod foo
",
)
.test_round_trip(false)
.arg("--list")
.stdout("Available recipes:\n foo ... # Comment\n")
.run();
}
#[test]
fn doc_attribute_on_module() {
Test::new()
.write("foo.just", "")
.justfile(
r#"
# Suppressed comment
[doc: "Comment"]
mod foo
"#,
)
.test_round_trip(false)
.arg("--list")
.stdout("Available recipes:\n foo ... # Comment\n")
.run();
}
#[test]
fn bad_module_attribute_fails() {
Test::new()
.write("foo.just", "")
.justfile(
r#"
[no-cd]
mod foo
"#,
)
.test_round_trip(false)
.arg("--list")
.stderr("error: Module `foo` has invalid attribute `no-cd`\n ——▶ justfile:2:5\n\n2 │ mod foo\n │ ^^^\n")
.status(EXIT_FAILURE)
.run();
}

View File

@ -80,7 +80,7 @@ error: Expected identifier, but found ']'
} }
test! { test! {
name: unattached_attribute_before_comment, name: extraneous_attribute_before_comment,
justfile: r#" justfile: r#"
[no-exit-message] [no-exit-message]
# This is a doc comment # This is a doc comment
@ -88,25 +88,31 @@ hello:
@exit 100 @exit 100
"#, "#,
stderr: r#" stderr: r#"
error: Expected '@', '[', or identifier, but found comment error: Extraneous attribute
justfile:2:1 justfile:1:1
2 # This is a doc comment 1 [no-exit-message]
^^^^^^^^^^^^^^^^^^^^^^^ ^
"#, "#,
status: EXIT_FAILURE, status: EXIT_FAILURE,
} }
test! { test! {
name: unattached_attribute_before_empty_line, name: extraneous_attribute_before_empty_line,
justfile: r#" justfile: r#"
[no-exit-message] [no-exit-message]
hello: hello:
@exit 100 @exit 100
"#, "#,
stderr: "error: Expected '@', '[', or identifier, but found end of line\n ——▶ justfile:2:1\n\n2 │ \n │ ^\n", stderr: "
error: Extraneous attribute
justfile:1:1
1 [no-exit-message]
^
",
status: EXIT_FAILURE, status: EXIT_FAILURE,
} }