Lines
99.44 %
Functions
95.12 %
Branches
100 %
/*
* Hurl (https://hurl.dev)
* Copyright (C) 2025 Orange
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
use hurl_core::ast::{
Assert, Base64, Body, BooleanOption, Bytes, Capture, CertificateAttributeName, Comment, Cookie,
CookiePath, CountOption, DurationOption, Entry, EntryOption, File, FilenameParam,
FilenameValue, FilterValue, Hex, HurlFile, IntegerValue, JsonValue, KeyValue, LineTerminator,
Method, MultilineString, MultipartParam, NaturalOption, Number, OptionKind, Placeholder,
Predicate, PredicateFuncValue, PredicateValue, Query, QueryValue, Regex, RegexValue, Request,
Response, Section, SectionValue, StatusValue, Template, VariableDefinition, VariableValue,
VersionValue, I64, U64,
};
use hurl_core::typing::{Count, Duration, DurationUnit, ToSource};
/// Lint a parsed `HurlFile` to a string.
pub fn lint_hurl_file(file: &HurlFile) -> String {
file.lint()
}
/// Lint something (usually a Hurl AST node) to a string.
trait Lint {
fn lint(&self) -> String;
impl Lint for Assert {
fn lint(&self) -> String {
let mut s = String::new();
self.line_terminators
.iter()
.for_each(|lt| s.push_str(&lint_lt(lt, false)));
s.push_str(&self.query.lint());
if !self.filters.is_empty() {
s.push(' ');
let filters = self
.filters
.map(|(_, f)| f.value.lint())
.collect::<Vec<_>>()
.join(" ");
s.push_str(&filters);
s.push_str(&self.predicate.lint());
s.push_str(&lint_lt(&self.line_terminator0, true));
s
impl Lint for Base64 {
s.push_str("base64,");
s.push_str(self.source.as_str());
s.push(';');
impl Lint for Body {
s.push_str(&self.value.lint());
impl Lint for BooleanOption {
match self {
BooleanOption::Literal(value) => value.to_string(),
BooleanOption::Placeholder(value) => value.lint(),
impl Lint for Bytes {
Bytes::Json(value) => value.lint(),
Bytes::Xml(value) => value.clone(),
Bytes::MultilineString(value) => value.lint(),
Bytes::OnelineString(value) => value.lint(),
Bytes::Base64(value) => value.lint(),
Bytes::File(value) => value.lint(),
Bytes::Hex(value) => value.lint(),
impl Lint for Capture {
s.push_str(&self.name.lint());
s.push(':');
if self.redacted {
s.push_str("redact");
impl Lint for CertificateAttributeName {
self.to_source().to_string()
impl Lint for Cookie {
impl Lint for CookiePath {
impl Lint for Comment {
format!("#{}", self.value.trim_end())
impl Lint for Count {
self.to_string()
impl Lint for CountOption {
CountOption::Literal(value) => value.lint(),
CountOption::Placeholder(value) => value.lint(),
impl Lint for Entry {
s.push_str(&self.request.lint());
if let Some(response) = &self.response {
s.push_str(&response.lint());
impl Lint for EntryOption {
s.push_str(&self.kind.lint());
impl Lint for File {
s.push_str("file,");
s.push_str(&self.filename.lint());
impl Lint for FilenameParam {
s.push_str(&self.key.lint());
impl Lint for FilenameValue {
if let Some(content_type) = &self.content_type {
s.push_str(&content_type.lint());
impl Lint for FilterValue {
s.push_str(self.identifier());
FilterValue::Decode { encoding, .. } => {
s.push_str(&encoding.lint());
FilterValue::Format { fmt, .. } => {
s.push_str(&fmt.lint());
FilterValue::JsonPath { expr, .. } => {
s.push_str(&expr.lint());
FilterValue::Nth { n, .. } => {
s.push_str(&n.lint());
FilterValue::Regex { value, .. } => {
s.push_str(&value.lint());
FilterValue::Replace {
old_value,
new_value,
..
} => {
s.push_str(&old_value.lint());
s.push_str(&new_value.lint());
FilterValue::Split { sep, .. } => {
s.push_str(&sep.lint());
FilterValue::ReplaceRegex {
pattern, new_value, ..
s.push_str(&pattern.lint());
FilterValue::ToDate { fmt, .. } => {
FilterValue::UrlQueryParam { param, .. } => {
s.push_str(¶m.lint());
FilterValue::XPath { expr, .. } => {
FilterValue::Base64Decode
| FilterValue::Base64Encode
| FilterValue::Base64UrlSafeDecode
| FilterValue::Base64UrlSafeEncode
| FilterValue::Count
| FilterValue::DaysAfterNow
| FilterValue::DaysBeforeNow
| FilterValue::First
| FilterValue::HtmlEscape
| FilterValue::HtmlUnescape
| FilterValue::Last
| FilterValue::Location
| FilterValue::ToFloat
| FilterValue::ToHex
| FilterValue::ToInt
| FilterValue::ToString
| FilterValue::UrlDecode
| FilterValue::UrlEncode => {}
impl Lint for Hex {
s.push_str("hex,");
impl Lint for HurlFile {
self.entries.iter().for_each(|e| s.push_str(&e.lint()));
impl Lint for IntegerValue {
IntegerValue::Literal(value) => value.lint(),
IntegerValue::Placeholder(value) => value.lint(),
impl Lint for I64 {
impl Lint for JsonValue {
impl Lint for KeyValue {
if !self.value.is_empty() {
fn lint_lt(lt: &LineTerminator, is_trailing: bool) -> String {
if let Some(comment) = <.comment {
if is_trailing {
// if line terminator is a trailing terminator, we keep the leading whitespaces
// to keep user alignment.
s.push_str(lt.space0.as_str());
s.push_str(&comment.lint());
// We always terminate a file by a newline
if lt.newline.value.is_empty() {
s.push('\n');
} else {
s.push_str(<.newline.value);
impl Lint for Method {
impl Lint for MultipartParam {
MultipartParam::Param(param) => s.push_str(¶m.lint()),
MultipartParam::FilenameParam(param) => s.push_str(¶m.lint()),
impl Lint for MultilineString {
impl Lint for NaturalOption {
NaturalOption::Literal(value) => value.lint(),
NaturalOption::Placeholder(value) => value.lint(),
impl Lint for Number {
impl Lint for OptionKind {
let value = match self {
OptionKind::AwsSigV4(value) => value.lint(),
OptionKind::CaCertificate(value) => value.lint(),
OptionKind::ClientCert(value) => value.lint(),
OptionKind::ClientKey(value) => value.lint(),
OptionKind::Compressed(value) => value.lint(),
OptionKind::ConnectTo(value) => value.lint(),
OptionKind::ConnectTimeout(value) => {
lint_duration_option(value, DurationUnit::MilliSecond)
OptionKind::Delay(value) => lint_duration_option(value, DurationUnit::MilliSecond),
OptionKind::Header(value) => value.lint(),
OptionKind::Http10(value) => value.lint(),
OptionKind::Http11(value) => value.lint(),
OptionKind::Http2(value) => value.lint(),
OptionKind::Http3(value) => value.lint(),
OptionKind::Insecure(value) => value.lint(),
OptionKind::IpV4(value) => value.lint(),
OptionKind::IpV6(value) => value.lint(),
OptionKind::FollowLocation(value) => value.lint(),
OptionKind::FollowLocationTrusted(value) => value.lint(),
OptionKind::LimitRate(value) => value.lint(),
OptionKind::MaxRedirect(value) => value.lint(),
OptionKind::MaxTime(value) => lint_duration_option(value, DurationUnit::MilliSecond),
OptionKind::NetRc(value) => value.lint(),
OptionKind::NetRcFile(value) => value.lint(),
OptionKind::NetRcOptional(value) => value.lint(),
OptionKind::Output(value) => value.lint(),
OptionKind::PathAsIs(value) => value.lint(),
OptionKind::PinnedPublicKey(value) => value.lint(),
OptionKind::Proxy(value) => value.lint(),
OptionKind::Repeat(value) => value.lint(),
OptionKind::Resolve(value) => value.lint(),
OptionKind::Retry(value) => value.lint(),
OptionKind::RetryInterval(value) => {
OptionKind::Skip(value) => value.lint(),
OptionKind::UnixSocket(value) => value.lint(),
OptionKind::User(value) => value.lint(),
OptionKind::Variable(value) => value.lint(),
OptionKind::Verbose(value) => value.lint(),
OptionKind::VeryVerbose(value) => value.lint(),
s.push_str(&value);
impl Lint for Query {
s.push_str(self.value.identifier());
match &self.value {
QueryValue::Status => {}
QueryValue::Version => {}
QueryValue::Url => {}
QueryValue::Header { name, .. } => {
s.push_str(&name.lint());
QueryValue::Cookie { expr, .. } => {
QueryValue::Body => {}
QueryValue::Xpath { expr, .. } => {
QueryValue::Jsonpath { expr, .. } => {
QueryValue::Regex { value, .. } => {
QueryValue::Variable { name, .. } => {
QueryValue::Duration => {}
QueryValue::Bytes => {}
QueryValue::Sha256 => {}
QueryValue::Md5 => {}
QueryValue::Certificate { attribute_name, .. } => {
s.push_str(&attribute_name.lint());
QueryValue::Ip => {}
QueryValue::Redirects => {}
impl Lint for Placeholder {
impl Lint for Predicate {
if self.not {
s.push_str("not");
s.push_str(&self.predicate_func.value.lint());
impl Lint for PredicateFuncValue {
PredicateFuncValue::Equal { value, .. } => {
PredicateFuncValue::NotEqual { value, .. } => {
PredicateFuncValue::GreaterThan { value, .. } => {
PredicateFuncValue::GreaterThanOrEqual { value, .. } => {
PredicateFuncValue::LessThan { value, .. } => {
PredicateFuncValue::LessThanOrEqual { value, .. } => {
PredicateFuncValue::StartWith { value, .. } => {
PredicateFuncValue::EndWith { value, .. } => {
PredicateFuncValue::Contain { value, .. } => {
PredicateFuncValue::Include { value, .. } => {
PredicateFuncValue::Match { value, .. } => {
PredicateFuncValue::IsInteger
| PredicateFuncValue::IsFloat
| PredicateFuncValue::IsBoolean
| PredicateFuncValue::IsString
| PredicateFuncValue::IsCollection
| PredicateFuncValue::IsDate
| PredicateFuncValue::IsIsoDate
| PredicateFuncValue::Exist
| PredicateFuncValue::IsEmpty
| PredicateFuncValue::IsNumber
| PredicateFuncValue::IsIpv4
| PredicateFuncValue::IsIpv6 => {}
impl Lint for PredicateValue {
PredicateValue::Base64(value) => value.lint(),
PredicateValue::Bool(value) => value.to_string(),
PredicateValue::File(value) => value.lint(),
PredicateValue::Hex(value) => value.lint(),
PredicateValue::MultilineString(value) => value.lint(),
PredicateValue::Null => "null".to_string(),
PredicateValue::Number(value) => value.lint(),
PredicateValue::Placeholder(value) => value.lint(),
PredicateValue::Regex(value) => value.lint(),
PredicateValue::String(value) => value.lint(),
impl Lint for Regex {
impl Lint for RegexValue {
RegexValue::Template(value) => value.lint(),
RegexValue::Regex(value) => value.lint(),
impl Lint for Request {
s.push_str(&self.method.lint());
s.push_str(&self.url.lint());
self.headers.iter().for_each(|h| s.push_str(&h.lint()));
// We rewrite our file and reorder the various section.
if let Some(section) = get_option_section(self) {
s.push_str(§ion.lint());
if let Some(section) = get_query_params_section(self) {
if let Some(section) = get_basic_auth_section(self) {
if let Some(section) = get_form_params_section(self) {
if let Some(section) = get_multipart_section(self) {
if let Some(section) = get_cookies_section(self) {
if let Some(body) = &self.body {
s.push_str(&body.lint());
impl Lint for Response {
s.push_str(&self.version.value.lint());
s.push_str(&self.status.value.lint());
if let Some(section) = get_captures_section(self) {
if let Some(section) = get_asserts_section(self) {
impl Lint for Section {
s.push('[');
s.push(']');
impl Lint for SectionValue {
SectionValue::QueryParams(params, _) => {
params.iter().for_each(|p| s.push_str(&p.lint()));
SectionValue::BasicAuth(Some(auth)) => {
s.push_str(&auth.lint());
SectionValue::BasicAuth(_) => {}
SectionValue::FormParams(params, _) => {
SectionValue::MultipartFormData(params, _) => {
SectionValue::Cookies(cookies) => {
cookies.iter().for_each(|c| s.push_str(&c.lint()));
SectionValue::Captures(captures) => {
captures.iter().for_each(|c| s.push_str(&c.lint()));
SectionValue::Asserts(asserts) => {
asserts.iter().for_each(|a| s.push_str(&a.lint()));
SectionValue::Options(options) => {
options.iter().for_each(|o| s.push_str(&o.lint()));
impl Lint for StatusValue {
impl Lint for Template {
impl Lint for U64 {
impl Lint for VariableDefinition {
s.push_str(&self.name);
s.push('=');
impl Lint for VariableValue {
impl Lint for VersionValue {
fn get_asserts_section(response: &Response) -> Option<&Section> {
for s in &response.sections {
if let SectionValue::Asserts(_) = s.value {
return Some(s);
None
fn get_captures_section(response: &Response) -> Option<&Section> {
if let SectionValue::Captures(_) = s.value {
fn get_cookies_section(request: &Request) -> Option<&Section> {
for s in &request.sections {
if let SectionValue::Cookies(_) = s.value {
fn get_form_params_section(request: &Request) -> Option<&Section> {
if let SectionValue::FormParams(_, _) = s.value {
fn get_option_section(request: &Request) -> Option<&Section> {
if let SectionValue::Options(_) = s.value {
fn get_multipart_section(request: &Request) -> Option<&Section> {
if let SectionValue::MultipartFormData(_, _) = s.value {
fn get_query_params_section(request: &Request) -> Option<&Section> {
if let SectionValue::QueryParams(_, _) = s.value {
fn get_basic_auth_section(request: &Request) -> Option<&Section> {
if let SectionValue::BasicAuth(Some(_)) = s.value {
fn lint_duration_option(option: &DurationOption, default_unit: DurationUnit) -> String {
match option {
DurationOption::Literal(duration) => lint_duration(duration, default_unit),
DurationOption::Placeholder(expr) => expr.lint(),
fn lint_duration(duration: &Duration, default_unit: DurationUnit) -> String {
s.push_str(&duration.value.lint());
let unit = duration.unit.unwrap_or(default_unit);
s.push_str(&unit.to_string());
#[cfg(test)]
mod tests {
use crate::linter::lint_hurl_file;
use hurl_core::parser;
#[test]
fn test_lint_hurl_file() {
let src = r#"
# comment 1
#comment 2 with trailing spaces
GET https://foo.com
[Form]
bar : baz
[Options]
location : true
HTTP 200"#;
let file = parser::parse_hurl_file(src).unwrap();
let linted = lint_hurl_file(&file);
assert_eq!(
linted,
r#"
location: true
bar: baz
HTTP 200
"#
);